Repository: kubernetes-client/javascript (ts)

Issue:
Unable to deserialize duplicated resource names
**Describe the bug**

The generic client is unable to correctly deserialize resources correctly that overlap the `kind` name.
E.g. querying a custom resource of kind `Probe` with group `monitoring.coreos.com/v1` results in an empty class of type `V1Probe`.
This is because the serializer does not take the group into account while deserializing.

**Client Version**
e.g. `1.1.2`

**Server Version**
e.g. `1.30.1`

**To Reproduce**

1. Create a custom resource with an overlapping name (e.g. install the prometheus operator).
2. create an instance
3. try to query that instance: see code snippet

**Expected behavior**

The client should return the plane K8s object when the type is unknown.


**Example Code**

```
const kc = new KubeConfig();
kc.loadFromDefault();
const client = new KubernetesObjectApi(kc);
const p = await client.read({
    apiVersion: 'monitoring.coreos.com/v1',
    kind: 'Probe',
    metadata: {
        name: 'my-probe',
        namespace: 'default',
    },
});
```

**Environment (please complete the following information):**

- OS: Linux
- Node.js version: 20

**Additional context**

I think to correctly fix this, the ObjectSerializer has to use the whole unique name of a resource (group version and kind) to identify an object.
The group information is currently not available so I guess the converter has to be adapted to provide that info.

Pull request description:
feat: add model registry to object serializer
Partially fixes #2384 by adding a model registry where dedicated serializers can be registered.
The models registered in the this registry always have priority over the internal types. (This is because the model registry is more strict in type checking)

Patch:
diff --git a/src/serializer.ts b/src/serializer.ts
index 29c3fc14..919a5df0 100644
--- a/src/serializer.ts
+++ b/src/serializer.ts
@@ -1,5 +1,13 @@
 import { ObjectSerializer as InternalSerializer, V1ObjectMeta } from './gen/models/ObjectSerializer.js';
 
+type KubernetesObjectHeader = {
+    apiVersion: string;
+    kind: string;
+};
+
+const isKubernetesObject = (data: unknown): data is KubernetesObjectHeader =>
+    !!data && typeof data === 'object' && 'apiVersion' in data && 'kind' in data;
+
 type AttributeType = {
     name: string;
     baseName: string;
@@ -38,30 +46,38 @@ class KubernetesObject {
             format: '',
         },
     ];
-}
-
-const isKubernetesObject = (data: unknown): boolean =>
-    !!data && typeof data === 'object' && 'apiVersion' in data && 'kind' in data;
 
-/**
- * Wraps the ObjectSerializer to support custom resources and generic Kubernetes objects.
- */
-export class ObjectSerializer extends InternalSerializer {
-    public static serialize(data: any, type: string, format: string = ''): any {
-        const obj = InternalSerializer.serialize(data, type, format);
-        if (obj !== data) {
-            return obj;
+    public serialize(): any {
+        const instance: Record<string, any> = {};
+        for (const attributeType of KubernetesObject.attributeTypeMap) {
+            const value = this[attributeType.baseName];
+            if (value !== undefined) {
+                instance[attributeType.name] = InternalSerializer.serialize(
+                    this[attributeType.baseName],
+                    attributeType.type,
+                    attributeType.format,
+                );
+            }
+        }
+        // add all unknown properties as is.
+        for (const [key, value] of Object.entries(this)) {
+            if (KubernetesObject.attributeTypeMap.find((t) => t.name === key)) {
+                continue;
+            }
+            instance[key] = value;
         }
+        return instance;
+    }
 
+    public static fromUnknown(data: unknown): KubernetesObject {
         if (!isKubernetesObject(data)) {
-            return obj;
+            throw new Error(`Unable to deseriliaze non-Kubernetes object ${data}.`);
         }
-
-        const instance: Record<string, any> = {};
+        const instance = new KubernetesObject();
         for (const attributeType of KubernetesObject.attributeTypeMap) {
             const value = data[attributeType.baseName];
             if (value !== undefined) {
-                instance[attributeType.name] = InternalSerializer.serialize(
+                instance[attributeType.name] = InternalSerializer.deserialize(
                     data[attributeType.baseName],
                     attributeType.type,
                     attributeType.format,
@@ -77,11 +93,102 @@ export class ObjectSerializer extends InternalSerializer {
         }
         return instance;
     }
+}
 
-    public static deserialize(data: any, type: string, format: string = ''): any {
-        const obj = InternalSerializer.deserialize(data, type, format);
+export interface Serializer {
+    serialize(data: any, type: string, format?: string): any;
+    deserialize(data: any, type: string, format?): any;
+}
+
+export type GroupVersionKind = {
+    group: string;
+    version: string;
+    kind: string;
+};
+
+type ModelRegistry = {
+    [gv: string]: {
+        [kind: string]: Serializer;
+    };
+};
+
+const gvString = ({ group, version }: GroupVersionKind): string => [group, version].join('/');
+
+const gvkFromObject = (obj: KubernetesObjectHeader): GroupVersionKind => {
+    const [g, v] = obj.apiVersion.split('/');
+    return {
+        kind: obj.kind,
+        group: v ? g : '',
+        version: v ? v : g,
+    };
+};
+
+/**
+ * Default serializer that uses the KubernetesObject to serialize and deserialize
+ * any object using only the minimum required attributes.
+ */
+export const defaultSerializer: Serializer = {
+    serialize: (data: any, type: string, format?: string): any => {
+        if (data instanceof KubernetesObject) {
+            return data.serialize();
+        }
+        return KubernetesObject.fromUnknown(data).serialize();
+    },
+    deserialize: (data: any, type: string, format?): any => {
+        return KubernetesObject.fromUnknown(data);
+    },
+};
+
+/**
+ * Wraps the ObjectSerializer to support custom resources and generic Kubernetes objects.
+ *
+ * CustomResources that are unknown to the ObjectSerializer can be registered
+ * by using ObjectSerializer.registerModel().
+ */
+export class ObjectSerializer extends InternalSerializer {
+    private static modelRegistry: ModelRegistry = {};
+
+    /**
+     * Adds a dedicated seriliazer for a Kubernetes resource.
+     * Every resource is uniquly identified using its group, version and kind.
+     * @param gvk
+     * @param serializer
+     */
+    public static registerModel(gvk: GroupVersionKind, serializer: Serializer) {
+        const gv = gvString(gvk);
+        const kinds = (this.modelRegistry[gv] ??= {});
+        if (kinds[gvk.kind]) {
+            throw new Error(`Kind ${gvk.kind} of ${gv} is already defined`);
+        }
+        kinds[gvk.kind] = serializer;
+    }
+
+    /**
+     * Removes all registered models from the registry.
+     */
+    public static clearModelRegistry(): void {
+        this.modelRegistry = {};
+    }
+
+    private static getSerializerForObject(obj: unknown): undefined | Serializer {
+        if (!isKubernetesObject(obj)) {
+            return undefined;
+        }
+        const gvk = gvkFromObject(obj);
+        return ObjectSerializer.modelRegistry[gvString(gvk)]?.[gvk.kind];
+    }
+
+    public static serialize(data: any, type: string, format: string = ''): any {
+        const serializer = ObjectSerializer.getSerializerForObject(data);
+        if (serializer) {
+            return serializer.serialize(data, type, format);
+        }
+        if (data instanceof KubernetesObject) {
+            return data.serialize();
+        }
+
+        const obj = InternalSerializer.serialize(data, type, format);
         if (obj !== data) {
-            // the serializer knows the type and already deserialized it.
             return obj;
         }
 
@@ -89,11 +196,11 @@ export class ObjectSerializer extends InternalSerializer {
             return obj;
         }
 
-        const instance = new KubernetesObject();
+        const instance: Record<string, any> = {};
         for (const attributeType of KubernetesObject.attributeTypeMap) {
             const value = data[attributeType.baseName];
             if (value !== undefined) {
-                instance[attributeType.name] = InternalSerializer.deserialize(
+                instance[attributeType.name] = InternalSerializer.serialize(
                     data[attributeType.baseName],
                     attributeType.type,
                     attributeType.format,
@@ -109,4 +216,22 @@ export class ObjectSerializer extends InternalSerializer {
         }
         return instance;
     }
+
+    public static deserialize(data: any, type: string, format: string = ''): any {
+        const serializer = ObjectSerializer.getSerializerForObject(data);
+        if (serializer) {
+            return serializer.deserialize(data, type, format);
+        }
+        const obj = InternalSerializer.deserialize(data, type, format);
+        if (obj !== data) {
+            // the serializer knows the type and already deserialized it.
+            return obj;
+        }
+
+        if (!isKubernetesObject(data)) {
+            return obj;
+        }
+
+        return KubernetesObject.fromUnknown(data);
+    }
 }


Test patch:
diff --git a/src/serializer_test.ts b/src/serializer_test.ts
index 9ed7d5be..a0976d02 100644
--- a/src/serializer_test.ts
+++ b/src/serializer_test.ts
@@ -1,8 +1,31 @@
-import { describe, it } from 'node:test';
+import { afterEach, beforeEach, describe, it } from 'node:test';
 import { deepEqual, deepStrictEqual } from 'node:assert';
-import { ObjectSerializer } from './serializer.js';
+import { defaultSerializer, ObjectSerializer } from './serializer.js';
 
 describe('ObjectSerializer', () => {
+    beforeEach(() => {
+        ObjectSerializer.registerModel(
+            {
+                group: 'my-group.io',
+                version: 'v1',
+                kind: 'V1MyCustomResource',
+            },
+            defaultSerializer,
+        );
+        ObjectSerializer.registerModel(
+            {
+                group: 'my-group.io',
+                version: 'v1',
+                kind: 'Deployment',
+            },
+            defaultSerializer,
+        );
+    });
+
+    afterEach(() => {
+        ObjectSerializer.clearModelRegistry();
+    });
+
     describe('serialize', () => {
         it('should serialize a known object', () => {
             const s = {
@@ -47,6 +70,98 @@ describe('ObjectSerializer', () => {
             });
         });
 
+        [
+            {
+                name: 'should serialize a registered custom object',
+                input: {
+                    type: 'V1MyCustomResource',
+                    obj: {
+                        apiVersion: 'my-group.io/v1',
+                        kind: 'MyCustomResource',
+                        metadata: {
+                            name: 'k8s-js-client-test',
+                            namespace: 'default',
+                            creationTimestamp: new Date('2022-01-01T00:00:00.000Z'),
+                        },
+                        data: {
+                            key: 'value',
+                        },
+                    },
+                },
+                expected: {
+                    apiVersion: 'my-group.io/v1',
+                    kind: 'MyCustomResource',
+                    metadata: {
+                        name: 'k8s-js-client-test',
+                        namespace: 'default',
+                        creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        uid: undefined,
+                        annotations: undefined,
+                        labels: undefined,
+                        finalizers: undefined,
+                        generateName: undefined,
+                        selfLink: undefined,
+                        resourceVersion: undefined,
+                        generation: undefined,
+                        ownerReferences: undefined,
+                        deletionTimestamp: undefined,
+                        deletionGracePeriodSeconds: undefined,
+                        managedFields: undefined,
+                    },
+                    data: {
+                        key: 'value',
+                    },
+                },
+            },
+            {
+                name: 'should serialize a registered custom object with a duplicated core resource kind',
+                input: {
+                    type: 'V1Deployment',
+                    obj: {
+                        apiVersion: 'my-group.io/v1',
+                        kind: 'Deployment',
+                        metadata: {
+                            name: 'k8s-js-client-test',
+                            namespace: 'default',
+                            creationTimestamp: new Date('2022-01-01T00:00:00.000Z'),
+                        },
+                        data: {
+                            key: 'value',
+                        },
+                    },
+                },
+                expected: {
+                    apiVersion: 'my-group.io/v1',
+                    kind: 'Deployment',
+                    metadata: {
+                        name: 'k8s-js-client-test',
+                        namespace: 'default',
+                        creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        uid: undefined,
+                        annotations: undefined,
+                        labels: undefined,
+                        finalizers: undefined,
+                        generateName: undefined,
+                        selfLink: undefined,
+                        resourceVersion: undefined,
+                        generation: undefined,
+                        ownerReferences: undefined,
+                        deletionTimestamp: undefined,
+                        deletionGracePeriodSeconds: undefined,
+                        managedFields: undefined,
+                    },
+                    data: {
+                        key: 'value',
+                    },
+                },
+            },
+        ].forEach(({ name, input, expected }) => {
+            it(name, () => {
+                const res = ObjectSerializer.serialize(input.obj, input.type);
+                deepStrictEqual(res, expected);
+            });
+        });
+
         it('should serialize a unknown kubernetes object', () => {
             const s = {
                 apiVersion: 'v1alpha1',
@@ -160,5 +275,73 @@ describe('ObjectSerializer', () => {
             const res = ObjectSerializer.serialize(s, 'unknown');
             deepStrictEqual(res, s);
         });
+
+        [
+            {
+                name: 'should deserialize a registered custom object',
+                input: {
+                    type: 'V1MyCustomResource',
+                    obj: {
+                        apiVersion: 'my-group.io/v1',
+                        kind: 'MyCustomResource',
+                        metadata: {
+                            name: 'k8s-js-client-test',
+                            namespace: 'default',
+                            creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        },
+                        data: {
+                            key: 'value',
+                        },
+                    },
+                },
+                expected: {
+                    apiVersion: 'my-group.io/v1',
+                    kind: 'MyCustomResource',
+                    metadata: {
+                        name: 'k8s-js-client-test',
+                        namespace: 'default',
+                        creationTimestamp: new Date('2022-01-01T00:00:00.000Z'),
+                    },
+                    data: {
+                        key: 'value',
+                    },
+                },
+            },
+            {
+                name: 'should deserialize a registered custom object with a duplicated core resource kind',
+                input: {
+                    type: 'V1Deployment',
+                    obj: {
+                        apiVersion: 'my-group.io/v1',
+                        kind: 'Deployment',
+                        metadata: {
+                            name: 'k8s-js-client-test',
+                            namespace: 'default',
+                            creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        },
+                        data: {
+                            key: 'value',
+                        },
+                    },
+                },
+                expected: {
+                    apiVersion: 'my-group.io/v1',
+                    kind: 'Deployment',
+                    metadata: {
+                        name: 'k8s-js-client-test',
+                        namespace: 'default',
+                        creationTimestamp: new Date('2022-01-01T00:00:00.000Z'),
+                    },
+                    data: {
+                        key: 'value',
+                    },
+                },
+            },
+        ].forEach(({ name, input, expected }) => {
+            it(name, () => {
+                const res = ObjectSerializer.deserialize(input.obj, input.type);
+                deepEqual(res, expected);
+            });
+        });
     });
 });