diff --git a/src/serializer.ts b/src/serializer.ts index 29c3fc144e..919a5df082 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 = {}; + 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 = {}; + 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 = {}; 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); + } } diff --git a/src/serializer_test.ts b/src/serializer_test.ts index 9ed7d5be97..a0976d0293 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); + }); + }); }); });