Repository: kubernetes-client/javascript
Pull request #1170 (2023-07-18 01:59:54)

Add KubernetesObjectApi for v1
This is largely the same as the implementation of object.ts in master but for node-fetch. Removed in https://github.com/kubernetes-client/javascript/pull/829 to get tests working for v1.


Patch:
diff --git a/examples/typescript/apply/apply-example.ts b/examples/typescript/apply/apply-example.ts
index 0dc2572e..d3bf2056 100644
--- a/examples/typescript/apply/apply-example.ts
+++ b/examples/typescript/apply/apply-example.ts
@@ -33,11 +33,11 @@ export async function apply(specPath: string): Promise<k8s.KubernetesObject[]> {
             await client.read(spec);
             // we got the resource, so it exists, so patch it
             const response = await client.patch(spec);
-            created.push(response.body);
+            created.push(response);
         } catch (e) {
             // we did not get the resource, so it does not exist, so create it
             const response = await client.create(spec);
-            created.push(response.body);
+            created.push(response);
         }
     }
     return created;
diff --git a/package-lock.json b/package-lock.json
index 6c323793..dd3ef331 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -573,9 +573,9 @@
             }
         },
         "node_modules/@types/node": {
-            "version": "20.4.2",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
-            "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw=="
+            "version": "20.4.1",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.1.tgz",
+            "integrity": "sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg=="
         },
         "node_modules/@types/node-fetch": {
             "version": "2.6.3",
@@ -2380,9 +2380,9 @@
             }
         },
         "node_modules/nock": {
-            "version": "13.3.2",
-            "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.2.tgz",
-            "integrity": "sha512-CwbljitiWJhF1gL83NbanhoKs1l23TDlRioNraPTZrzZIEooPemrHRj5m0FZCPkB1ecdYCSWWGcHysJgX/ngnQ==",
+            "version": "13.3.1",
+            "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.1.tgz",
+            "integrity": "sha512-vHnopocZuI93p2ccivFyGuUfzjq2fxNyNurp7816mlT5V5HF4SzXu8lvLrVzBbNqzs+ODooZ6OksuSUNM7Njkw==",
             "dev": true,
             "dependencies": {
                 "debug": "^4.1.0",
@@ -4195,9 +4195,9 @@
             }
         },
         "@types/node": {
-            "version": "20.4.2",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
-            "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw=="
+            "version": "20.4.1",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.1.tgz",
+            "integrity": "sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg=="
         },
         "@types/node-fetch": {
             "version": "2.6.3",
@@ -5547,9 +5547,9 @@
             "dev": true
         },
         "nock": {
-            "version": "13.3.2",
-            "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.2.tgz",
-            "integrity": "sha512-CwbljitiWJhF1gL83NbanhoKs1l23TDlRioNraPTZrzZIEooPemrHRj5m0FZCPkB1ecdYCSWWGcHysJgX/ngnQ==",
+            "version": "13.3.1",
+            "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.1.tgz",
+            "integrity": "sha512-vHnopocZuI93p2ccivFyGuUfzjq2fxNyNurp7816mlT5V5HF4SzXu8lvLrVzBbNqzs+ODooZ6OksuSUNM7Njkw==",
             "dev": true,
             "requires": {
                 "debug": "^4.1.0",
diff --git a/src/index.ts b/src/index.ts
index 7df73079..af5bb374 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,4 +13,5 @@ export * from './top';
 export * from './cp';
 export * from './patch';
 export * from './metrics';
+export * from './object';
 export { ConfigOptions, User, Cluster, Context } from './config_types';
diff --git a/src/object.ts b/src/object.ts
new file mode 100644
index 00000000..5207fe38
--- /dev/null
+++ b/src/object.ts
@@ -0,0 +1,673 @@
+import {
+    ApiException,
+    Configuration,
+    HttpMethod,
+    RequestContext,
+    ResponseContext,
+    SecurityAuthentication,
+    V1APIResource,
+    V1APIResourceList,
+    V1DeleteOptions,
+    V1Status,
+} from './api';
+import { KubeConfig } from './config';
+import { KubernetesListObject, KubernetesObject } from './types';
+import { ObjectSerializer } from './util';
+import { from, mergeMap, of } from './gen/rxjsStub';
+import { PatchStrategy } from './patch';
+
+/** Kubernetes API verbs. */
+type KubernetesApiAction = 'create' | 'delete' | 'patch' | 'read' | 'list' | 'replace';
+
+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.
+ */
+export class KubernetesObjectApi {
+    /**
+     * Create a KubernetesObjectApi object from the provided KubeConfig.  This method should be used rather than
+     * [[KubeConfig.makeApiClient]] so we can properly determine the default namespace if one is provided by the current
+     * context.
+     *
+     * @param kc Valid Kubernetes config
+     * @return Properly instantiated [[KubernetesObjectApi]] object
+     */
+    public static makeApiClient(kc: KubeConfig): KubernetesObjectApi {
+        const client = kc.makeApiClient(KubernetesObjectApi);
+        client.setDefaultNamespace(kc);
+        return client;
+    }
+
+    /** Initialize the default namespace.  May be overwritten by context. */
+    protected defaultNamespace: string = 'default';
+
+    /** Cache resource API response. */
+    protected apiVersionResourceCache: Record<string, V1APIResourceList> = {};
+
+    constructor(protected configuration: Configuration) {}
+
+    /**
+     * Create any Kubernetes resource.
+     * @param spec Kubernetes resource spec.
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized
+     *        dryRun directive will result in an error response and no further processing of the request. Valid values
+     *        are: - All: all dry run stages will be processed
+     * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes. The
+     *        value must be less than or 128 characters long, and only contain printable characters, as defined by
+     *        https://golang.org/pkg/unicode/#IsPrint.
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and [[KubernetesObject]].
+     */
+    public async create<T extends KubernetesObject | KubernetesObject>(
+        spec: T,
+        pretty?: string,
+        dryRun?: string,
+        fieldManager?: string,
+        options?: Configuration,
+    ): Promise<T> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'spec' is not null or undefined
+        if (spec === null || spec === undefined) {
+            throw new Error('Required parameter spec was null or undefined when calling create.');
+        }
+
+        const localVarPath = await this.specUriPath(spec, 'create');
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.POST);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (dryRun !== undefined) {
+            requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string'));
+        }
+
+        if (fieldManager !== undefined) {
+            requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string'));
+        }
+
+        // Body Params
+        const contentType = ObjectSerializer.getPreferredMediaType([]);
+        requestContext.setHeaderParam('Content-Type', contentType);
+        const serializedBody = ObjectSerializer.stringify(
+            ObjectSerializer.serialize(spec, 'any'),
+            contentType,
+        );
+        requestContext.setBody(serializedBody);
+
+        return this.requestPromise<T>(requestContext);
+    }
+
+    /**
+     * Delete any Kubernetes resource.
+     * @param spec Kubernetes resource spec
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized
+     *        dryRun directive will result in an error response and no further processing of the request. Valid values
+     *        are: - All: all dry run stages will be processed
+     * @param gracePeriodSeconds The duration in seconds before the object should be deleted. Value must be non-negative
+     *        integer. The value zero indicates delete immediately. If this value is nil, the default grace period for
+     *        the specified type will be used. Defaults to a per object value if not specified. zero means delete
+     *        immediately.
+     * @param orphanDependents Deprecated: please use the PropagationPolicy, this field will be deprecated in
+     *        1.7. Should the dependent objects be orphaned. If true/false, the \&quot;orphan\&quot; finalizer will be
+     *        added to/removed from the object\&#39;s finalizers list. Either this field or PropagationPolicy may be
+     *        set, but not both.
+     * @param propagationPolicy Whether and how garbage collection will be performed. Either this field or
+     *        OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in
+     *        the metadata.finalizers and the resource-specific default policy. Acceptable values are:
+     *        \&#39;Orphan\&#39; - orphan the dependents; \&#39;Background\&#39; - allow the garbage collector to delete
+     *        the dependents in the background; \&#39;Foreground\&#39; - a cascading policy that deletes all dependents
+     *        in the foreground.
+     * @param body See [[V1DeleteOptions]].
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and a Kubernetes [[V1Status]].
+     */
+    public async delete(
+        spec: KubernetesObject,
+        pretty?: string,
+        dryRun?: string,
+        gracePeriodSeconds?: number,
+        orphanDependents?: boolean,
+        propagationPolicy?: string,
+        body?: V1DeleteOptions,
+        options?: Configuration,
+    ): Promise<V1Status> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'spec' is not null or undefined
+        if (spec === null || spec === undefined) {
+            throw new Error('Required parameter spec was null or undefined when calling delete.');
+        }
+
+        const localVarPath = await this.specUriPath(spec, 'delete');
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.DELETE);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (dryRun !== undefined) {
+            requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string'));
+        }
+
+        if (gracePeriodSeconds !== undefined) {
+            requestContext.setQueryParam(
+                'gracePeriodSeconds',
+                ObjectSerializer.serialize(gracePeriodSeconds, 'number'),
+            );
+        }
+
+        if (orphanDependents !== undefined) {
+            requestContext.setQueryParam(
+                'orphanDependents',
+                ObjectSerializer.serialize(orphanDependents, 'boolean'),
+            );
+        }
+
+        if (propagationPolicy !== undefined) {
+            requestContext.setQueryParam(
+                'propagationPolicy',
+                ObjectSerializer.serialize(propagationPolicy, 'string'),
+            );
+        }
+
+        // Body Params
+        if (body) {
+            const contentType = ObjectSerializer.getPreferredMediaType([]);
+            requestContext.setHeaderParam('Content-Type', contentType);
+            const serializedBody = ObjectSerializer.stringify(
+                ObjectSerializer.serialize(body, 'V1DeleteOptions'),
+                contentType,
+            );
+            requestContext.setBody(serializedBody);
+        }
+
+        return this.requestPromise<V1Status>(requestContext, 'V1Status');
+    }
+
+    /**
+     * Patch any Kubernetes resource.
+     * @param spec Kubernetes resource spec
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized
+     *        dryRun directive will result in an error response and no further processing of the request. Valid values
+     *        are: - All: all dry run stages will be processed
+     * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes.  The
+     *        value must be less than or 128 characters long, and only contain printable characters, as defined by
+     *        https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests
+     *        (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch,
+     *        StrategicMergePatch).
+     * @param force Force is going to \&quot;force\&quot; Apply requests.  It means user will re-acquire conflicting
+     *        fields owned by other people. Force flag must be unset for non-apply patch requests.
+     * @param patchStrategy Content-Type header used to control how the patch will be performed. See
+     *        See https://kubernetes.io/docs/tasks/run-application/update-api-object-kubectl-patch/
+     *        for details.
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and [[KubernetesObject]].
+     */
+    public async patch<T extends KubernetesObject | KubernetesObject>(
+        spec: T,
+        pretty?: string,
+        dryRun?: string,
+        fieldManager?: string,
+        force?: boolean,
+        patchStrategy: PatchStrategy = PatchStrategy.StrategicMergePatch,
+        options?: Configuration,
+    ): Promise<T> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'spec' is not null or undefined
+        if (spec === null || spec === undefined) {
+            throw new Error('Required parameter spec was null or undefined when calling patch.');
+        }
+
+        const localVarPath = await this.specUriPath(spec, 'patch');
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.PATCH);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+        requestContext.setHeaderParam('Content-Type', patchStrategy);
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (dryRun !== undefined) {
+            requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string'));
+        }
+
+        if (fieldManager !== undefined) {
+            requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string'));
+        }
+
+        if (force !== undefined) {
+            requestContext.setQueryParam('force', ObjectSerializer.serialize(force, 'boolean'));
+        }
+
+        // Body Params
+        const serializedBody = ObjectSerializer.stringify(
+            ObjectSerializer.serialize(spec, 'any'),
+            // TODO: use the patch content type once ObjectSerializer supports it.
+            'application/json',
+        );
+        requestContext.setBody(serializedBody);
+
+        return this.requestPromise<T>(requestContext);
+    }
+
+    /**
+     * Read any Kubernetes resource.
+     * @param spec Kubernetes resource spec
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param exact Should the export be exact.  Exact export maintains cluster-specific fields like
+     *        \&#39;Namespace\&#39;. Deprecated. Planned for removal in 1.18.
+     * @param exportt Should this value be exported.  Export strips fields that a user can not
+     *        specify. Deprecated. Planned for removal in 1.18.
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and [[KubernetesObject]].
+     */
+    public async read<T extends KubernetesObject | KubernetesObject>(
+        spec: T,
+        pretty?: string,
+        exact?: boolean,
+        exportt?: boolean,
+        options?: Configuration,
+    ): Promise<T> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'spec' is not null or undefined
+        if (spec === null || spec === undefined) {
+            throw new Error('Required parameter spec was null or undefined when calling read.');
+        }
+        // verify required parameter 'kind' is not null or undefined
+        if (spec.kind === null || spec.kind === undefined) {
+            throw new Error('Required parameter spec.kind was null or undefined when calling read.');
+        }
+        if (!spec.apiVersion) {
+            throw new Error('Required parameter spec.apiVersion was null or undefined when calling read.');
+        }
+
+        const localVarPath = await this.specUriPath(spec, 'read');
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (exact !== undefined) {
+            requestContext.setQueryParam('exact', ObjectSerializer.serialize(exact, 'boolean'));
+        }
+
+        if (exportt !== undefined) {
+            requestContext.setQueryParam('export', ObjectSerializer.serialize(exportt, 'boolean'));
+        }
+
+        return this.requestPromise<T>(requestContext);
+    }
+
+    /**
+     * List any Kubernetes resources.
+     * @param apiVersion api group and version of the form <apiGroup>/<version>
+     * @param kind Kubernetes resource kind
+     * @param namespace list resources in this namespace
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param exact Should the export be exact.  Exact export maintains cluster-specific fields like
+     *        \&#39;Namespace\&#39;. Deprecated. Planned for removal in 1.18.
+     * @param exportt Should this value be exported.  Export strips fields that a user can not
+     *        specify. Deprecated. Planned for removal in 1.18.
+     * @param fieldSelector A selector to restrict the list of returned objects by their fields. Defaults to everything.
+     * @param labelSelector A selector to restrict the list of returned objects by their labels. Defaults to everything.
+     * @param limit Number of returned resources.
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and [[KubernetesListObject<KubernetesObject>]].
+     */
+    public async list<T extends KubernetesObject | KubernetesObject>(
+        apiVersion: string,
+        kind: string,
+        namespace?: string,
+        pretty?: string,
+        exact?: boolean,
+        exportt?: boolean,
+        fieldSelector?: string,
+        labelSelector?: string,
+        limit?: number,
+        continueToken?: string,
+        options?: Configuration,
+    ): Promise<KubernetesListObject<T>> {
+        const _config = options || this.configuration;
+
+        // verify required parameters 'apiVersion', 'kind' is not null or undefined
+        if (apiVersion === null || apiVersion === undefined) {
+            throw new Error('Required parameter apiVersion was null or undefined when calling list.');
+        }
+        if (kind === null || kind === undefined) {
+            throw new Error('Required parameter kind was null or undefined when calling list.');
+        }
+
+        const localVarPath = await this.specUriPath(
+            {
+                apiVersion,
+                kind,
+                metadata: {
+                    namespace,
+                },
+            },
+            'list',
+        );
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (exact !== undefined) {
+            requestContext.setQueryParam('exact', ObjectSerializer.serialize(exact, 'boolean'));
+        }
+
+        if (exportt !== undefined) {
+            requestContext.setQueryParam('export', ObjectSerializer.serialize(exportt, 'boolean'));
+        }
+
+        if (fieldSelector !== undefined) {
+            requestContext.setQueryParam(
+                'fieldSelector',
+                ObjectSerializer.serialize(fieldSelector, 'string'),
+            );
+        }
+
+        if (labelSelector !== undefined) {
+            requestContext.setQueryParam(
+                'labelSelector',
+                ObjectSerializer.serialize(labelSelector, 'string'),
+            );
+        }
+
+        if (limit !== undefined) {
+            requestContext.setQueryParam('limit', ObjectSerializer.serialize(limit, 'number'));
+        }
+
+        if (continueToken !== undefined) {
+            requestContext.setQueryParam('continue', ObjectSerializer.serialize(continueToken, 'string'));
+        }
+
+        return this.requestPromise<KubernetesListObject<T>>(requestContext);
+    }
+
+    /**
+     * Replace any Kubernetes resource.
+     * @param spec Kubernetes resource spec
+     * @param pretty If \&#39;true\&#39;, then the output is pretty printed.
+     * @param dryRun When present, indicates that modifications should not be persisted. An invalid or unrecognized
+     *        dryRun directive will result in an error response and no further processing of the request. Valid values
+     *        are: - All: all dry run stages will be processed
+     * @param fieldManager fieldManager is a name associated with the actor or entity that is making these changes. The
+     *        value must be less than or 128 characters long, and only contain printable characters, as defined by
+     *        https://golang.org/pkg/unicode/#IsPrint.
+     * @param options Optional headers to use in the request.
+     * @return Promise containing the request response and [[KubernetesObject]].
+     */
+    public async replace<T extends KubernetesObject | KubernetesObject>(
+        spec: T,
+        pretty?: string,
+        dryRun?: string,
+        fieldManager?: string,
+        options?: Configuration,
+    ): Promise<T> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'spec' is not null or undefined
+        if (spec === null || spec === undefined) {
+            throw new Error('Required parameter spec was null or undefined when calling replace.');
+        }
+
+        const localVarPath = await this.specUriPath(spec, 'replace');
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.PUT);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        if (pretty !== undefined) {
+            requestContext.setQueryParam('pretty', ObjectSerializer.serialize(pretty, 'string'));
+        }
+
+        if (dryRun !== undefined) {
+            requestContext.setQueryParam('dryRun', ObjectSerializer.serialize(dryRun, 'string'));
+        }
+
+        if (fieldManager !== undefined) {
+            requestContext.setQueryParam('fieldManager', ObjectSerializer.serialize(fieldManager, 'string'));
+        }
+
+        // Body Params
+        const contentType = ObjectSerializer.getPreferredMediaType([]);
+        requestContext.setHeaderParam('Content-Type', contentType);
+        const serializedBody = ObjectSerializer.stringify(
+            ObjectSerializer.serialize(spec, 'any'),
+            contentType,
+        );
+        requestContext.setBody(serializedBody);
+
+        return this.requestPromise<T>(requestContext);
+    }
+
+    /** Set default namespace from current context, if available. */
+    protected setDefaultNamespace(kc: KubeConfig): string {
+        if (kc.currentContext) {
+            const currentContext = kc.getContextObject(kc.currentContext);
+            if (currentContext && currentContext.namespace) {
+                this.defaultNamespace = currentContext.namespace;
+            }
+        }
+        return this.defaultNamespace;
+    }
+
+    /**
+     * Use spec information to construct resource URI path.  If any required information in not provided, an Error is
+     * thrown.  If an `apiVersion` is not provided, 'v1' is used.  If a `metadata.namespace` is not provided for a
+     * request that requires one, the context default is used, if available, if not, 'default' is used.
+     *
+     * @param spec Kubernetes resource spec which must define kind and apiVersion properties.
+     * @param action API action, see [[K8sApiAction]].
+     * @return tail of resource-specific URIDeploym
+     */
+    protected async specUriPath(spec: KubernetesObject, action: KubernetesApiAction): Promise<string> {
+        if (!spec.kind) {
+            throw new Error('Required spec property kind is not set');
+        }
+        if (!spec.apiVersion) {
+            spec.apiVersion = 'v1';
+        }
+        if (!spec.metadata) {
+            spec.metadata = {};
+        }
+        const resource = await this.resource(spec.apiVersion, spec.kind);
+        if (!resource) {
+            throw new Error(`Unrecognized API version and kind: ${spec.apiVersion} ${spec.kind}`);
+        }
+        if (resource.namespaced && !spec.metadata.namespace && action !== 'list') {
+            spec.metadata.namespace = this.defaultNamespace;
+        }
+        const parts = [this.apiVersionPath(spec.apiVersion)];
+        if (resource.namespaced && spec.metadata.namespace) {
+            parts.push('namespaces', encodeURIComponent(String(spec.metadata.namespace)));
+        }
+        parts.push(resource.name);
+        if (action !== 'create' && action !== 'list') {
+            if (!spec.metadata.name) {
+                throw new Error('Required spec property name is not set');
+            }
+            parts.push(encodeURIComponent(String(spec.metadata.name)));
+        }
+        return parts.join('/').toLowerCase();
+    }
+
+    /** Return root of API path up to API version. */
+    protected apiVersionPath(apiVersion: string): string {
+        const api = apiVersion.includes('/') ? 'apis' : 'api';
+        return '/' + [api, apiVersion].join('/');
+    }
+
+    /**
+     * Get metadata from Kubernetes API for resources described by `kind` and `apiVersion`.  If it is unable to find the
+     * resource `kind` under the provided `apiVersion`, `undefined` is returned.
+     *
+     * This method caches responses from the Kubernetes API to use for future requests.  If the cache for apiVersion
+     * exists but the kind is not found the request is attempted again.
+     *
+     * @param apiVersion Kubernetes API version, e.g., 'v1' or 'apps/v1'.
+     * @param kind Kubernetes resource kind, e.g., 'Pod' or 'Namespace'.
+     * @return Promise of the resource metadata or `undefined` if the resource is not found.
+     */
+    protected async resource(
+        apiVersion: string,
+        kind: string,
+        options?: Configuration,
+    ): Promise<V1APIResource | undefined> {
+        const _config = options || this.configuration;
+
+        // verify required parameter 'apiVersion' is not null or undefined
+        if (apiVersion === null || apiVersion === undefined) {
+            throw new Error('Required parameter apiVersion was null or undefined when calling resource');
+        }
+        // verify required parameter 'kind' is not null or undefined
+        if (kind === null || kind === undefined) {
+            throw new Error('Required parameter kind was null or undefined when calling resource');
+        }
+
+        if (this.apiVersionResourceCache[apiVersion]) {
+            const resource = this.apiVersionResourceCache[apiVersion].resources.find((r) => r.kind === kind);
+            if (resource) {
+                return resource;
+            }
+        }
+
+        const localVarPath = this.apiVersionPath(apiVersion);
+
+        const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET);
+        requestContext.setHeaderParam('Accept', 'application/json, */*;q=0.8');
+
+        try {
+            const getApiResponse = await this.requestPromise<V1APIResourceList>(
+                requestContext,
+                'V1APIResourceList',
+            );
+            this.apiVersionResourceCache[apiVersion] = getApiResponse;
+            return this.apiVersionResourceCache[apiVersion].resources.find((r) => r.kind === kind);
+        } catch (e) {
+            if (e instanceof Error) {
+                e.message = `Failed to fetch resource metadata for ${apiVersion}/${kind}: ${e.message}`;
+            }
+            throw e;
+        }
+    }
+
+    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,
+        options?: Configuration,
+    ): Promise<T> {
+        const _config = options || this.configuration;
+
+        let authMethod: SecurityAuthentication | undefined;
+        // Apply auth methods
+        authMethod = _config.authMethods.BearerToken;
+        if (authMethod?.applySecurityAuthentication) {
+            await authMethod?.applySecurityAuthentication(requestContext);
+        }
+
+        const defaultAuth: SecurityAuthentication | undefined =
+            options?.authMethods?.default || this.configuration?.authMethods?.default;
+        if (defaultAuth?.applySecurityAuthentication) {
+            await defaultAuth?.applySecurityAuthentication(requestContext);
+        }
+
+        // build promise chain
+        let middlewarePreObservable = from<RequestContext>(Promise.resolve(requestContext));
+        for (const middleware of this.configuration.middleware) {
+            middlewarePreObservable = middlewarePreObservable.pipe(
+                mergeMap((ctx: RequestContext) => middleware.pre(ctx)),
+            );
+        }
+
+        return middlewarePreObservable
+            .pipe(mergeMap((ctx: RequestContext) => this.configuration.httpApi.send(ctx)))
+            .pipe(
+                mergeMap((response: ResponseContext) => {
+                    let middlewarePostObservable = of(response);
+                    for (const middleware of this.configuration.middleware) {
+                        middlewarePostObservable = middlewarePostObservable.pipe(
+                            mergeMap((rsp: ResponseContext) => middleware.post(rsp)),
+                        );
+                    }
+                    return middlewarePostObservable.pipe((rsp: ResponseContext) =>
+                        this.processResponse<T>(rsp, type),
+                    );
+                }),
+            )
+            .toPromise();
+    }
+
+    protected async processResponse<T extends KubernetesObject | KubernetesObject>(
+        response: ResponseContext,
+        type?: string,
+    ): Promise<T> {
+        const contentType = ObjectSerializer.normalizeMediaType(response.headers['content-type']);
+        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);
+            }
+
+            if (!type) {
+                throw new Error('Unable to determine type of Kubernetes type of response.');
+            }
+
+            return ObjectSerializer.deserialize(data, type) as T;
+        }
+
+        throw new ApiException<string | Buffer | undefined>(
+            response.httpStatusCode,
+            'Unsuccessful HTTP Request',
+            await response.getBodyAsAny(),
+            response.headers,
+        );
+    }
+}
diff --git a/src/patch.ts b/src/patch.ts
index 8595f210..b13aba14 100644
--- a/src/patch.ts
+++ b/src/patch.ts
@@ -1,6 +1,13 @@
-export class PatchUtils {
-    public static PATCH_FORMAT_JSON_PATCH: string = 'application/json-patch+json';
-    public static PATCH_FORMAT_JSON_MERGE_PATCH: string = 'application/merge-patch+json';
-    public static PATCH_FORMAT_STRATEGIC_MERGE_PATCH: string = 'application/strategic-merge-patch+json';
-    public static PATCH_FORMAT_APPLY_YAML: string = 'application/apply-patch+yaml';
+/**
+ * Valid Content-Type header values for patch operations.  See
+ * https://kubernetes.io/docs/tasks/run-application/update-api-object-kubectl-patch/
+ * for details.
+ */
+export enum PatchStrategy {
+    /** Diff-like JSON format. */
+    JsonPatch = 'application/json-patch+json',
+    /** Simple merge. */
+    MergePatch = 'application/merge-patch+json',
+    /** Merge with different strategies depending on field metadata. */
+    StrategicMergePatch = 'application/strategic-merge-patch+json',
 }


Test patch:
diff --git a/src/object_test.ts b/src/object_test.ts
new file mode 100644
index 00000000..805a8837
--- /dev/null
+++ b/src/object_test.ts
@@ -0,0 +1,2128 @@
+import { fail } from 'assert';
+import { expect } from 'chai';
+import nock = require('nock');
+import { Configuration, V1APIResource, V1APIResourceList, V1Secret } from './api';
+import { KubeConfig } from './config';
+import { KubernetesObjectApi } from './object';
+import { KubernetesObject } from './types';
+import { of } from './gen/rxjsStub';
+import { ApiException } from '../dist';
+
+describe('KubernetesObject', () => {
+    const testConfigOptions = {
+        clusters: [{ name: 'dc', server: 'https://d.i.y' }],
+        users: [{ name: 'ian', password: 'mackaye' }],
+        contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian' }],
+        currentContext: 'dischord',
+    };
+
+    describe('makeApiClient', () => {
+        it('should create the client', () => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions(testConfigOptions);
+            const c = KubernetesObjectApi.makeApiClient(kc);
+            expect(c).to.be.ok;
+            expect((c as any).defaultNamespace).to.equal('default');
+        });
+
+        it('should set the default namespace from context', () => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions({
+                clusters: [{ name: 'dc', server: 'https://d.i.y' }],
+                users: [{ name: 'ian', password: 'mackaye' }],
+                contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian', namespace: 'straight-edge' }],
+                currentContext: 'dischord',
+            });
+            const c = KubernetesObjectApi.makeApiClient(kc);
+            expect(c).to.be.ok;
+            expect((c as any).defaultNamespace).to.equal('straight-edge');
+        });
+    });
+
+    class KubernetesObjectApiTest extends KubernetesObjectApi {
+        constructor(public configuration: Configuration) {
+            super(configuration);
+        }
+
+        public static makeApiClient(kc?: KubeConfig): KubernetesObjectApiTest {
+            if (!kc) {
+                kc = new KubeConfig();
+                kc.loadFromOptions(testConfigOptions);
+            }
+            const client = kc.makeApiClient(KubernetesObjectApiTest);
+            client.setDefaultNamespace(kc);
+            return client;
+        }
+        public apiVersionResourceCache: Record<string, V1APIResourceList> = {};
+        public async specUriPath(spec: KubernetesObject, method: any): Promise<string> {
+            return super.specUriPath(spec, method);
+        }
+        public async resource(apiVersion: string, kind: string): Promise<V1APIResource | undefined> {
+            return super.resource(apiVersion, kind);
+        }
+    }
+
+    const contentTypeJsonHeader = {
+        'Content-Type': 'application/json',
+    };
+
+    const resourceBodies = {
+        core: `{
+  "groupVersion": "v1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "Binding",
+      "name": "bindings",
+      "namespaced": true
+    },
+    {
+      "kind": "ComponentStatus",
+      "name": "componentstatuses",
+      "namespaced": false
+    },
+    {
+      "kind": "ConfigMap",
+      "name": "configmaps",
+      "namespaced": true
+    },
+    {
+      "kind": "Endpoints",
+      "name": "endpoints",
+      "namespaced": true
+    },
+    {
+      "kind": "Event",
+      "name": "events",
+      "namespaced": true
+    },
+    {
+      "kind": "LimitRange",
+      "name": "limitranges",
+      "namespaced": true
+    },
+    {
+      "kind": "Namespace",
+      "name": "namespaces",
+      "namespaced": false
+    },
+    {
+      "kind": "Namespace",
+      "name": "namespaces/finalize",
+      "namespaced": false
+    },
+    {
+      "kind": "Namespace",
+      "name": "namespaces/status",
+      "namespaced": false
+    },
+    {
+      "kind": "Node",
+      "name": "nodes",
+      "namespaced": false
+    },
+    {
+      "kind": "NodeProxyOptions",
+      "name": "nodes/proxy",
+      "namespaced": false
+    },
+    {
+      "kind": "Node",
+      "name": "nodes/status",
+      "namespaced": false
+    },
+    {
+      "kind": "PersistentVolumeClaim",
+      "name": "persistentvolumeclaims",
+      "namespaced": true
+    },
+    {
+      "kind": "PersistentVolumeClaim",
+      "name": "persistentvolumeclaims/status",
+      "namespaced": true
+    },
+    {
+      "kind": "PersistentVolume",
+      "name": "persistentvolumes",
+      "namespaced": false
+    },
+    {
+      "kind": "PersistentVolume",
+      "name": "persistentvolumes/status",
+      "namespaced": false
+    },
+    {
+      "kind": "Pod",
+      "name": "pods",
+      "namespaced": true
+    },
+    {
+      "kind": "PodAttachOptions",
+      "name": "pods/attach",
+      "namespaced": true
+    },
+    {
+      "kind": "Binding",
+      "name": "pods/binding",
+      "namespaced": true
+    },
+    {
+      "group": "policy",
+      "kind": "Eviction",
+      "name": "pods/eviction",
+      "namespaced": true,
+      "version": "v1beta1"
+    },
+    {
+      "kind": "PodExecOptions",
+      "name": "pods/exec",
+      "namespaced": true
+    },
+    {
+      "kind": "Pod",
+      "name": "pods/log",
+      "namespaced": true
+    },
+    {
+      "kind": "PodPortForwardOptions",
+      "name": "pods/portforward",
+      "namespaced": true
+    },
+    {
+      "kind": "PodProxyOptions",
+      "name": "pods/proxy",
+      "namespaced": true
+    },
+    {
+      "kind": "Pod",
+      "name": "pods/status",
+      "namespaced": true
+    },
+    {
+      "kind": "PodTemplate",
+      "name": "podtemplates",
+      "namespaced": true
+    },
+    {
+      "kind": "ReplicationController",
+      "name": "replicationcontrollers",
+      "namespaced": true
+    },
+    {
+      "group": "autoscaling",
+      "kind": "Scale",
+      "name": "replicationcontrollers/scale",
+      "namespaced": true,
+      "version": "v1"
+    },
+    {
+      "kind": "ReplicationController",
+      "name": "replicationcontrollers/status",
+      "namespaced": true
+    },
+    {
+      "kind": "ResourceQuota",
+      "name": "resourcequotas",
+      "namespaced": true
+    },
+    {
+      "kind": "ResourceQuota",
+      "name": "resourcequotas/status",
+      "namespaced": true
+    },
+    {
+      "kind": "Secret",
+      "name": "secrets",
+      "namespaced": true
+    },
+    {
+      "kind": "ServiceAccount",
+      "name": "serviceaccounts",
+      "namespaced": true
+    },
+    {
+      "kind": "Service",
+      "name": "services",
+      "namespaced": true
+    },
+    {
+      "kind": "ServiceProxyOptions",
+      "name": "services/proxy",
+      "namespaced": true
+    },
+    {
+      "kind": "Service",
+      "name": "services/status",
+      "namespaced": true
+    }
+  ]
+}`,
+
+        apps: `{
+  "apiVersion": "v1",
+  "groupVersion": "apps/v1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "ControllerRevision",
+      "name": "controllerrevisions",
+      "namespaced": true
+    },
+    {
+      "kind": "DaemonSet",
+      "name": "daemonsets",
+      "namespaced": true
+    },
+    {
+      "kind": "DaemonSet",
+      "name": "daemonsets/status",
+      "namespaced": true
+    },
+    {
+      "kind": "Deployment",
+      "name": "deployments",
+      "namespaced": true
+    },
+    {
+      "group": "autoscaling",
+      "kind": "Scale",
+      "name": "deployments/scale",
+      "namespaced": true,
+      "version": "v1"
+    },
+    {
+      "kind": "Deployment",
+      "name": "deployments/status",
+      "namespaced": true
+    },
+    {
+      "kind": "ReplicaSet",
+      "name": "replicasets",
+      "namespaced": true
+    },
+    {
+      "group": "autoscaling",
+      "kind": "Scale",
+      "name": "replicasets/scale",
+      "namespaced": true,
+      "version": "v1"
+    },
+    {
+      "kind": "ReplicaSet",
+      "name": "replicasets/status",
+      "namespaced": true
+    },
+    {
+      "kind": "StatefulSet",
+      "name": "statefulsets",
+      "namespaced": true
+    },
+    {
+      "group": "autoscaling",
+      "kind": "Scale",
+      "name": "statefulsets/scale",
+      "namespaced": true,
+      "version": "v1"
+    },
+    {
+      "kind": "StatefulSet",
+      "name": "statefulsets/status",
+      "namespaced": true
+    }
+  ]
+}`,
+        extensions: `{
+  "groupVersion": "extensions/v1beta1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "DaemonSet",
+      "name": "daemonsets",
+      "namespaced": true
+    },
+    {
+      "kind": "DaemonSet",
+      "name": "daemonsets/status",
+      "namespaced": true
+    },
+    {
+      "kind": "Deployment",
+      "name": "deployments",
+      "namespaced": true
+    },
+    {
+      "kind": "DeploymentRollback",
+      "name": "deployments/rollback",
+      "namespaced": true
+    },
+    {
+      "group": "extensions",
+      "kind": "Scale",
+      "name": "deployments/scale",
+      "namespaced": true,
+      "version": "v1beta1"
+    },
+    {
+      "kind": "Deployment",
+      "name": "deployments/status",
+      "namespaced": true
+    },
+    {
+      "kind": "Ingress",
+      "name": "ingresses",
+      "namespaced": true
+    },
+    {
+      "kind": "Ingress",
+      "name": "ingresses/status",
+      "namespaced": true
+    },
+    {
+      "kind": "NetworkPolicy",
+      "name": "networkpolicies",
+      "namespaced": true
+    },
+    {
+      "kind": "PodSecurityPolicy",
+      "name": "podsecuritypolicies",
+      "namespaced": false
+    },
+    {
+      "kind": "ReplicaSet",
+      "name": "replicasets",
+      "namespaced": true
+    },
+    {
+      "group": "extensions",
+      "kind": "Scale",
+      "name": "replicasets/scale",
+      "namespaced": true,
+      "version": "v1beta1"
+    },
+    {
+      "kind": "ReplicaSet",
+      "name": "replicasets/status",
+      "namespaced": true
+    },
+    {
+      "kind": "ReplicationControllerDummy",
+      "name": "replicationcontrollers",
+      "namespaced": true
+    },
+    {
+      "kind": "Scale",
+      "name": "replicationcontrollers/scale",
+      "namespaced": true
+    }
+  ]
+}`,
+        networking: `{
+  "apiVersion": "v1",
+  "groupVersion": "networking.k8s.io/v1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "NetworkPolicy",
+      "name": "networkpolicies",
+      "namespaced": true
+    }
+  ]
+}`,
+        rbac: `{
+  "apiVersion": "v1",
+  "groupVersion": "rbac.authorization.k8s.io/v1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "ClusterRoleBinding",
+      "name": "clusterrolebindings",
+      "namespaced": false
+    },
+    {
+      "kind": "ClusterRole",
+      "name": "clusterroles",
+      "namespaced": false
+    },
+    {
+      "kind": "RoleBinding",
+      "name": "rolebindings",
+      "namespaced": true
+    },
+    {
+      "kind": "Role",
+      "name": "roles",
+      "namespaced": true
+    }
+  ]
+}`,
+        storage: `{
+  "apiVersion": "v1",
+  "groupVersion": "storage.k8s.io/v1",
+  "kind": "APIResourceList",
+  "resources": [
+    {
+      "kind": "StorageClass",
+      "name": "storageclasses",
+      "namespaced": false
+    },
+    {
+      "kind": "VolumeAttachment",
+      "name": "volumeattachments",
+      "namespaced": false
+    },
+    {
+      "kind": "VolumeAttachment",
+      "name": "volumeattachments/status",
+      "namespaced": false
+    }
+  ]
+}`,
+    };
+
+    describe('specUriPath', () => {
+        it('should return a namespaced path', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: 'repeater',
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'patch');
+            expect(r).to.equal('/api/v1/namespaces/fugazi/services/repeater');
+            scope.done();
+        });
+
+        it('should default to apiVersion v1', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                kind: 'ServiceAccount',
+                metadata: {
+                    name: 'repeater',
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'patch');
+            expect(r).to.equal('/api/v1/namespaces/fugazi/serviceaccounts/repeater');
+            scope.done();
+        });
+
+        it('should default to context namespace', async () => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions({
+                clusters: [{ name: 'dc', server: 'https://d.i.y' }],
+                users: [{ name: 'ian', password: 'mackaye' }],
+                contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian', namespace: 'straight-edge' }],
+                currentContext: 'dischord',
+            });
+            const c = KubernetesObjectApiTest.makeApiClient(kc);
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Pod',
+                metadata: {
+                    name: 'repeater',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'patch');
+            expect(r).to.equal('/api/v1/namespaces/straight-edge/pods/repeater');
+            scope.done();
+        });
+
+        it('should default to default namespace', async () => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions({
+                clusters: [{ name: 'dc', server: 'https://d.i.y' }],
+                users: [{ name: 'ian', password: 'mackaye' }],
+                contexts: [{ name: 'dischord', cluster: 'dc', user: 'ian' }],
+                currentContext: 'dischord',
+            });
+            const c = KubernetesObjectApiTest.makeApiClient(kc);
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Pod',
+                metadata: {
+                    name: 'repeater',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'patch');
+            expect(r).to.equal('/api/v1/namespaces/default/pods/repeater');
+            scope.done();
+        });
+
+        it('should return a non-namespaced path', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Namespace',
+                metadata: {
+                    name: 'repeater',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'delete');
+            expect(r).to.equal('/api/v1/namespaces/repeater');
+            scope.done();
+        });
+
+        it('should return a namespaced path without name', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'create');
+            expect(r).to.equal('/api/v1/namespaces/fugazi/services');
+            scope.done();
+        });
+
+        it('should return a non-namespaced path without name', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Namespace',
+                metadata: {
+                    name: 'repeater',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'create');
+            expect(r).to.equal('/api/v1/namespaces');
+            scope.done();
+        });
+
+        it('should return a namespaced path for non-core resource', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'apps/v1',
+                kind: 'Deployment',
+                metadata: {
+                    name: 'repeater',
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/apis/apps/v1')
+                .reply(200, resourceBodies.apps, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'read');
+            expect(r).to.equal('/apis/apps/v1/namespaces/fugazi/deployments/repeater');
+            scope.done();
+        });
+
+        it('should return a non-namespaced path for non-core resource', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'rbac.authorization.k8s.io/v1',
+                kind: 'ClusterRole',
+                metadata: {
+                    name: 'repeater',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/apis/rbac.authorization.k8s.io/v1')
+                .reply(200, resourceBodies.rbac, contentTypeJsonHeader);
+            const r = await c.specUriPath(o, 'read');
+            expect(r).to.equal('/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater');
+            scope.done();
+        });
+
+        it('should handle a variety of resources', async () => {
+            const a = [
+                {
+                    apiVersion: 'v1',
+                    kind: 'Service',
+                    ns: true,
+                    p: '/api/v1',
+                    b: resourceBodies.core,
+                    e: '/api/v1/namespaces/fugazi/services/repeater',
+                },
+                {
+                    apiVersion: 'v1',
+                    kind: 'ServiceAccount',
+                    ns: true,
+                    p: '/api/v1',
+                    b: resourceBodies.core,
+                    e: '/api/v1/namespaces/fugazi/serviceaccounts/repeater',
+                },
+                {
+                    apiVersion: 'rbac.authorization.k8s.io/v1',
+                    kind: 'Role',
+                    ns: true,
+                    p: '/apis/rbac.authorization.k8s.io/v1',
+                    b: resourceBodies.rbac,
+                    e: '/apis/rbac.authorization.k8s.io/v1/namespaces/fugazi/roles/repeater',
+                },
+                {
+                    apiVersion: 'rbac.authorization.k8s.io/v1',
+                    kind: 'ClusterRole',
+                    ns: false,
+                    p: '/apis/rbac.authorization.k8s.io/v1',
+                    b: resourceBodies.rbac,
+                    e: '/apis/rbac.authorization.k8s.io/v1/clusterroles/repeater',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'NetworkPolicy',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/networkpolicies/repeater',
+                },
+                {
+                    apiVersion: 'networking.k8s.io/v1',
+                    kind: 'NetworkPolicy',
+                    ns: true,
+                    p: '/apis/networking.k8s.io/v1',
+                    b: resourceBodies.networking,
+                    e: '/apis/networking.k8s.io/v1/namespaces/fugazi/networkpolicies/repeater',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'Ingress',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/ingresses/repeater',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'DaemonSet',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/daemonsets/repeater',
+                },
+                {
+                    apiVersion: 'apps/v1',
+                    kind: 'DaemonSet',
+                    ns: true,
+                    p: '/apis/apps/v1',
+                    b: resourceBodies.apps,
+                    e: '/apis/apps/v1/namespaces/fugazi/daemonsets/repeater',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'Deployment',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/deployments/repeater',
+                },
+                {
+                    apiVersion: 'apps/v1',
+                    kind: 'Deployment',
+                    ns: true,
+                    p: '/apis/apps/v1',
+                    b: resourceBodies.apps,
+                    e: '/apis/apps/v1/namespaces/fugazi/deployments/repeater',
+                },
+                {
+                    apiVersion: 'storage.k8s.io/v1',
+                    kind: 'StorageClass',
+                    ns: false,
+                    p: '/apis/storage.k8s.io/v1',
+                    b: resourceBodies.storage,
+                    e: '/apis/storage.k8s.io/v1/storageclasses/repeater',
+                },
+            ];
+            for (const k of a) {
+                const c = KubernetesObjectApiTest.makeApiClient();
+                const o: KubernetesObject = {
+                    apiVersion: k.apiVersion,
+                    kind: k.kind,
+                    metadata: {
+                        name: 'repeater',
+                    },
+                };
+                if (k.ns) {
+                    o.metadata = o.metadata || {};
+                    o.metadata.namespace = 'fugazi';
+                }
+                const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader);
+                const r = await c.specUriPath(o, 'patch');
+                expect(r).to.equal(k.e);
+                scope.done();
+            }
+        });
+
+        it('should handle a variety of resources without names', async () => {
+            const a = [
+                {
+                    apiVersion: 'v1',
+                    kind: 'Service',
+                    ns: true,
+                    p: '/api/v1',
+                    b: resourceBodies.core,
+                    e: '/api/v1/namespaces/fugazi/services',
+                },
+                {
+                    apiVersion: 'v1',
+                    kind: 'ServiceAccount',
+                    ns: true,
+                    p: '/api/v1',
+                    b: resourceBodies.core,
+                    e: '/api/v1/namespaces/fugazi/serviceaccounts',
+                },
+                {
+                    apiVersion: 'rbac.authorization.k8s.io/v1',
+                    kind: 'Role',
+                    ns: true,
+                    p: '/apis/rbac.authorization.k8s.io/v1',
+                    b: resourceBodies.rbac,
+                    e: '/apis/rbac.authorization.k8s.io/v1/namespaces/fugazi/roles',
+                },
+                {
+                    apiVersion: 'rbac.authorization.k8s.io/v1',
+                    kind: 'ClusterRole',
+                    ns: false,
+                    p: '/apis/rbac.authorization.k8s.io/v1',
+                    b: resourceBodies.rbac,
+                    e: '/apis/rbac.authorization.k8s.io/v1/clusterroles',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'NetworkPolicy',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/networkpolicies',
+                },
+                {
+                    apiVersion: 'networking.k8s.io/v1',
+                    kind: 'NetworkPolicy',
+                    ns: true,
+                    p: '/apis/networking.k8s.io/v1',
+                    b: resourceBodies.networking,
+                    e: '/apis/networking.k8s.io/v1/namespaces/fugazi/networkpolicies',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'Ingress',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/ingresses',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'DaemonSet',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/daemonsets',
+                },
+                {
+                    apiVersion: 'apps/v1',
+                    kind: 'DaemonSet',
+                    ns: true,
+                    p: '/apis/apps/v1',
+                    b: resourceBodies.apps,
+                    e: '/apis/apps/v1/namespaces/fugazi/daemonsets',
+                },
+                {
+                    apiVersion: 'extensions/v1beta1',
+                    kind: 'Deployment',
+                    ns: true,
+                    p: '/apis/extensions/v1beta1',
+                    b: resourceBodies.extensions,
+                    e: '/apis/extensions/v1beta1/namespaces/fugazi/deployments',
+                },
+                {
+                    apiVersion: 'apps/v1',
+                    kind: 'Deployment',
+                    ns: true,
+                    p: '/apis/apps/v1',
+                    b: resourceBodies.apps,
+                    e: '/apis/apps/v1/namespaces/fugazi/deployments',
+                },
+                {
+                    apiVersion: 'storage.k8s.io/v1',
+                    kind: 'StorageClass',
+                    ns: false,
+                    p: '/apis/storage.k8s.io/v1',
+                    b: resourceBodies.storage,
+                    e: '/apis/storage.k8s.io/v1/storageclasses',
+                },
+            ];
+            for (const k of a) {
+                const c = KubernetesObjectApiTest.makeApiClient();
+                const o: KubernetesObject = {
+                    apiVersion: k.apiVersion,
+                    kind: k.kind,
+                };
+                if (k.ns) {
+                    o.metadata = { namespace: 'fugazi' };
+                }
+                const scope = nock('https://d.i.y').get(k.p).reply(200, k.b, contentTypeJsonHeader);
+                const r = await c.specUriPath(o, 'create');
+                expect(r).to.equal(k.e);
+                scope.done();
+            }
+        });
+
+        it('should throw an error if kind missing', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                metadata: {
+                    name: 'repeater',
+                    namespace: 'fugazi',
+                },
+            };
+            let thrown = false;
+            try {
+                await c.specUriPath(o, 'create');
+                expect.fail('should have thrown error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.equal('Required spec property kind is not set');
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+        });
+
+        it('should throw an error if name required and missing', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            let thrown = false;
+            try {
+                await c.specUriPath(o, 'read');
+                expect.fail('should have thrown error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.equal('Required spec property name is not set');
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+            scope.done();
+        });
+
+        it('should throw an error if resource is not valid', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const o = {
+                apiVersion: 'v1',
+                kind: 'Ingress',
+                metadata: {
+                    name: 'repeater',
+                    namespace: 'fugazi',
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            let thrown = false;
+            try {
+                await c.specUriPath(o, 'create');
+                expect.fail('should have thrown error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.equal('Unrecognized API version and kind: v1 Ingress');
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+            scope.done();
+        });
+    });
+
+    describe('resource', () => {
+        let client: KubernetesObjectApiTest;
+        before(function (this: Mocha.Context): void {
+            client = KubernetesObjectApiTest.makeApiClient();
+        });
+
+        it('should throw an error if apiVersion not set', async () => {
+            for (const a of [null, undefined]) {
+                let thrown = false;
+                try {
+                    await client.resource(a as unknown as string, 'Service');
+                } catch (e) {
+                    thrown = true;
+                    if (e instanceof Error) {
+                        expect(e.message).to.equal(
+                            'Required parameter apiVersion was null or undefined when calling resource',
+                        );
+                    } else {
+                        fail(`unknown exception: ${e}`);
+                    }
+                }
+                expect(thrown).to.be.true;
+            }
+        });
+
+        it('should throw an error if kind not set', async () => {
+            for (const a of [null, undefined]) {
+                let thrown = false;
+                try {
+                    await client.resource('v1', a as unknown as string);
+                } catch (e) {
+                    thrown = true;
+                    if (e instanceof Error) {
+                        expect(e.message).to.equal(
+                            'Required parameter kind was null or undefined when calling resource',
+                        );
+                    } else {
+                        fail(`unknown exception: ${e}`);
+                    }
+                }
+                expect(thrown).to.be.true;
+            }
+        });
+
+        it('should use middleware', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+
+            let preMiddlewareCalled = false;
+            let postMiddlewareCalled = false;
+            c.configuration.middleware.push({
+                pre: (context) => {
+                    preMiddlewareCalled = true;
+                    return of(context);
+                },
+                post: (context) => {
+                    postMiddlewareCalled = true;
+                    return of(context);
+                },
+            });
+
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            await c.resource('v1', 'Service');
+            expect(preMiddlewareCalled).to.be.true;
+            expect(postMiddlewareCalled).to.be.true;
+            scope.done();
+        });
+
+        it('should cache API response', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const s = await c.resource('v1', 'Service');
+            expect(s).to.be.ok;
+            if (!s) {
+                throw new Error('old TypeScript compiler');
+            }
+            expect(s.kind).to.equal('Service');
+            expect(s.name).to.equal('services');
+            expect(s.namespaced).to.be.true;
+            expect(c.apiVersionResourceCache).to.be.ok;
+            expect(c.apiVersionResourceCache.v1).to.be.ok;
+            const sa = await c.resource('v1', 'ServiceAccount');
+            expect(sa).to.be.ok;
+            if (!sa) {
+                throw new Error('old TypeScript compiler');
+            }
+            expect(sa.kind).to.equal('ServiceAccount');
+            expect(sa.name).to.equal('serviceaccounts');
+            expect(sa.namespaced).to.be.true;
+            const p = await c.resource('v1', 'Pod');
+            if (!p) {
+                throw new Error('old TypeScript compiler');
+            }
+            expect(p).to.be.ok;
+            expect(p.kind).to.equal('Pod');
+            expect(p.name).to.equal('pods');
+            expect(p.namespaced).to.be.true;
+            const pv = await c.resource('v1', 'PersistentVolume');
+            if (!pv) {
+                throw new Error('old TypeScript compiler');
+            }
+            expect(pv).to.be.ok;
+            expect(pv.kind).to.equal('PersistentVolume');
+            expect(pv.name).to.equal('persistentvolumes');
+            expect(pv.namespaced).to.be.false;
+            scope.done();
+        });
+
+        it('should re-request on cache miss', async () => {
+            const c = KubernetesObjectApiTest.makeApiClient();
+            c.apiVersionResourceCache.v1 = {
+                groupVersion: 'v1',
+                kind: 'APIResourceList',
+                resources: [
+                    {
+                        kind: 'Binding',
+                        name: 'bindings',
+                        namespaced: true,
+                    },
+                    {
+                        kind: 'ComponentStatus',
+                        name: 'componentstatuses',
+                        namespaced: false,
+                    },
+                ],
+            } as any;
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader);
+            const s = await c.resource('v1', 'Service');
+            expect(s).to.be.ok;
+            if (!s) {
+                throw new Error('old TypeScript compiler');
+            }
+            expect(s.kind).to.equal('Service');
+            expect(s.name).to.equal('services');
+            expect(s.namespaced).to.be.true;
+            expect(c.apiVersionResourceCache).to.be.ok;
+            expect(c.apiVersionResourceCache.v1).to.be.ok;
+            expect(c.apiVersionResourceCache.v1.resources.length).to.deep.equal(
+                JSON.parse(resourceBodies.core).resources.length,
+            );
+            scope.done();
+        });
+    });
+
+    describe('verbs', () => {
+        let client: KubernetesObjectApi;
+        before(() => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions(testConfigOptions);
+            client = KubernetesObjectApi.makeApiClient(kc);
+            (client as any).apiVersionResourceCache.v1 = JSON.parse(resourceBodies.core);
+        });
+
+        it('should modify resources with defaults', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: 'k8s-js-client-test',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            const methods = [
+                {
+                    m: client.create,
+                    v: 'POST',
+                    p: '/api/v1/namespaces/default/services',
+                    c: 201,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.patch,
+                    v: 'PATCH',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.read,
+                    v: 'GET',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.delete,
+                    v: 'DELETE',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "apiVersion": "v1",
+  "details": {
+    "kind": "services",
+    "name": "k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a"
+  },
+  "kind": "Status",
+  "metadata": {},
+  "status": "Success"
+}`,
+                },
+            ];
+            for (const m of methods) {
+                const scope = nock('https://d.i.y')
+                    .intercept(m.p, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s)
+                    .reply(m.c, m.b, contentTypeJsonHeader);
+                // TODO: Figure out why Typescript barfs if we do m.call
+                const hack_m = m.m as any;
+                await hack_m.call(client, s);
+                scope.done();
+            }
+        });
+
+        it('should modify resources with pretty set', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: 'k8s-js-client-test',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            const methods = [
+                {
+                    m: client.create,
+                    v: 'POST',
+                    p: '/api/v1/namespaces/default/services',
+                    c: 201,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.patch,
+                    v: 'PATCH',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.read,
+                    v: 'GET',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.delete,
+                    v: 'DELETE',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "apiVersion": "v1",
+  "details": {
+    "kind": "services",
+    "name": "k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a"
+  },
+  "kind": "Status",
+  "metadata": {},
+  "status": "Success"
+}`,
+                },
+            ];
+            for (const p of ['true', 'false']) {
+                for (const m of methods) {
+                    const scope = nock('https://d.i.y')
+                        .intercept(
+                            `${m.p}?pretty=${p}`,
+                            m.v,
+                            m.v === 'DELETE' || m.v === 'GET' ? undefined : s,
+                        )
+                        .reply(m.c, m.b, contentTypeJsonHeader);
+                    // TODO: Figure out why Typescript barfs if we do m.call
+                    const hack_m = m.m as any;
+                    await hack_m.call(client, s, p);
+                    scope.done();
+                }
+            }
+        });
+
+        it('should set dryRun', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: 'k8s-js-client-test',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            const methods = [
+                {
+                    m: client.create,
+                    v: 'POST',
+                    p: '/api/v1/namespaces/default/services',
+                    c: 201,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.patch,
+                    v: 'PATCH',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a",
+    "resourceVersion": "32373",
+    "creationTimestamp": "2020-05-11T17:34:25Z"
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.97.191.144",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                },
+                {
+                    m: client.delete,
+                    v: 'DELETE',
+                    p: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                    c: 200,
+                    b: `{
+  "apiVersion": "v1",
+  "details": {
+    "kind": "services",
+    "name": "k8s-js-client-test",
+    "uid": "6a43eddc-26bf-424e-ab30-cde3041a706a"
+  },
+  "kind": "Status",
+  "metadata": {},
+  "status": "Success"
+}`,
+                },
+            ];
+            for (const m of methods) {
+                const scope = nock('https://d.i.y')
+                    .intercept(`${m.p}?dryRun=All`, m.v, m.v === 'DELETE' || m.v === 'GET' ? undefined : s)
+                    .reply(m.c, m.b, contentTypeJsonHeader);
+                // TODO: Figure out why Typescript barfs if we do m.call
+                const hack_m = m.m as any;
+                await hack_m.call(client, s, undefined, 'All');
+                scope.done();
+            }
+        });
+
+        it('should replace a resource', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    annotations: {
+                        owner: 'test',
+                    },
+                    name: 'k8s-js-client-test',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .post('/api/v1/namespaces/default/services?fieldManager=ManageField', s)
+                .reply(
+                    201,
+                    `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821",
+    "resourceVersion": "41183",
+    "creationTimestamp": "2020-05-11T19:35:01Z",
+    "annotations": {
+      "owner": "test"
+    }
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.106.153.133",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                    contentTypeJsonHeader,
+                )
+                .put('/api/v1/namespaces/default/services/k8s-js-client-test?pretty=true', {
+                    kind: 'Service',
+                    apiVersion: 'v1',
+                    metadata: {
+                        name: 'k8s-js-client-test',
+                        namespace: 'default',
+                        selfLink: '/api/v1/namespaces/default/services/k8s-js-client-test',
+                        uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821',
+                        resourceVersion: '41183',
+                        creationTimestamp: '2020-05-11T19:35:01.000Z',
+                        annotations: {
+                            owner: 'test',
+                            test: '1',
+                        },
+                    },
+                    spec: {
+                        ports: [
+                            {
+                                protocol: 'TCP',
+                                port: 80,
+                                targetPort: 80,
+                            },
+                        ],
+                        selector: {
+                            app: 'sleep',
+                        },
+                        clusterIP: '10.106.153.133',
+                        type: 'ClusterIP',
+                        sessionAffinity: 'None',
+                    },
+                    status: {
+                        loadBalancer: {},
+                    },
+                })
+                .reply(
+                    200,
+                    `{
+  "kind": "Service",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "k8s-js-client-test",
+    "namespace": "default",
+    "selfLink": "/api/v1/namespaces/default/services/k8s-js-client-test",
+    "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821",
+    "resourceVersion": "41185",
+    "creationTimestamp": "2020-05-11T19:35:01Z",
+    "annotations": {
+      "owner": "test",
+      "test": "1"
+    }
+  },
+  "spec": {
+    "ports": [
+      {
+        "protocol": "TCP",
+        "port": 80,
+        "targetPort": 80
+      }
+    ],
+    "selector": {
+      "app": "sleep"
+    },
+    "clusterIP": "10.106.153.133",
+    "type": "ClusterIP",
+    "sessionAffinity": "None"
+  },
+  "status": {
+    "loadBalancer": {}
+  }
+}`,
+                    contentTypeJsonHeader,
+                )
+                .delete(
+                    '/api/v1/namespaces/default/services/k8s-js-client-test?gracePeriodSeconds=7&propagationPolicy=Foreground',
+                )
+                .reply(
+                    200,
+                    `{
+  "apiVersion": "v1",
+  "details": {
+    "kind": "services",
+    "name": "k8s-js-client-test",
+    "uid": "a4fd7a65-2af5-4ef1-a0bc-cb34a308b821"
+  },
+  "kind": "Status",
+  "metadata": {},
+  "status": "Success"
+}`,
+                    contentTypeJsonHeader,
+                );
+            const c = await client.create(s, undefined, undefined, 'ManageField');
+            (c.metadata.annotations as Record<string, string>).test = '1';
+            const r = await client.replace(c, 'true');
+            expect((r.metadata.annotations as Record<string, string>).test).to.equal('1');
+            expect(parseInt((r.metadata as any).resourceVersion, 10)).to.be.greaterThan(
+                parseInt((c.metadata as any).resourceVersion, 10),
+            );
+            await client.delete(s, undefined, undefined, 7, undefined, 'Foreground');
+            scope.done();
+        });
+
+        it('should read a resource', async () => {
+            const scope = nock('https://d.i.y')
+                .get('/api/v1/namespaces/default/secrets/test-secret-1')
+                .reply(
+                    200,
+                    {
+                        apiVersion: 'v1',
+                        kind: 'Secret',
+                        metadata: {
+                            name: 'test-secret-1',
+                            namespace: 'default',
+                            uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821',
+                            creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        },
+                        data: {
+                            key: 'value',
+                        },
+                    },
+                    contentTypeJsonHeader,
+                );
+            const secret = await client.read<V1Secret>({
+                apiVersion: 'v1',
+                kind: 'Secret',
+                metadata: {
+                    name: 'test-secret-1',
+                    namespace: 'default',
+                },
+            });
+            expect(secret).to.be.instanceof(V1Secret);
+            expect(secret.data).to.deep.equal({
+                key: 'value',
+            });
+            expect(secret.metadata).to.be.ok;
+            expect(secret.metadata!.creationTimestamp).to.deep.equal(new Date('2022-01-01T00:00:00.000Z'));
+            scope.done();
+        });
+
+        it('should read a custom resource', async () => {
+            interface CustomTestResource extends KubernetesObject {
+                spec?: {
+                    key: string;
+                };
+            }
+            (client as any).apiVersionResourceCache['example.com/v1'] = {
+                groupVersion: 'example.com/v1',
+                kind: 'APIResourceList',
+                resources: [
+                    {
+                        kind: 'CustomTestResource',
+                        name: 'customtestresources',
+                        namespaced: true,
+                    },
+                ],
+            };
+            const scope = nock('https://d.i.y')
+                .get('/apis/example.com/v1/namespaces/default/customtestresources/test-1')
+                .reply(
+                    200,
+                    {
+                        apiVersion: 'example.com/v1',
+                        kind: 'CustomTestResource',
+                        metadata: {
+                            name: 'test-1',
+                            namespace: 'default',
+                            uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821',
+                            creationTimestamp: '2022-01-01T00:00:00.000Z',
+                        },
+                        spec: {
+                            key: 'value',
+                        },
+                    },
+                    contentTypeJsonHeader,
+                );
+            const custom = await client.read<CustomTestResource>({
+                apiVersion: 'example.com/v1',
+                kind: 'CustomTestResource',
+                metadata: {
+                    name: 'test-1',
+                    namespace: 'default',
+                },
+            });
+            expect(custom.spec).to.deep.equal({
+                key: 'value',
+            });
+            expect(custom.metadata).to.be.ok;
+            // TODO(schrodit): this should be a Date rather than a string
+            expect(custom.metadata!.creationTimestamp).to.equal('2022-01-01T00:00:00.000Z');
+            scope.done();
+        });
+
+        it('should list resources in a namespace', async () => {
+            const scope = nock('https://d.i.y')
+                .get('/api/v1/namespaces/default/secrets')
+                .reply(
+                    200,
+                    {
+                        apiVersion: 'v1',
+                        kind: 'SecretList',
+                        items: [
+                            {
+                                apiVersion: 'v1',
+                                kind: 'Secret',
+                                metadata: {
+                                    name: 'test-secret-1',
+                                    uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821',
+                                },
+                            },
+                        ],
+                        metadata: {
+                            resourceVersion: '216532459',
+                            continue: 'abc',
+                        },
+                    },
+                    contentTypeJsonHeader,
+                );
+            const lr = await client.list<V1Secret>('v1', 'Secret', 'default');
+            const items = lr.items;
+            expect(items).to.have.length(1);
+            expect(items[0]).to.be.instanceof(V1Secret);
+            scope.done();
+        });
+
+        it('should list resources in all namespaces', async () => {
+            const scope = nock('https://d.i.y')
+                .get(
+                    '/api/v1/secrets?fieldSelector=metadata.name%3Dtest-secret1&labelSelector=app%3Dmy-app&limit=5',
+                )
+                .reply(
+                    200,
+                    {
+                        apiVersion: 'v1',
+                        kind: 'SecretList',
+                        items: [
+                            {
+                                apiVersion: 'v1',
+                                kind: 'Secret',
+                                metadata: {
+                                    name: 'test-secret-1',
+                                    uid: 'a4fd7a65-2af5-4ef1-a0bc-cb34a308b821',
+                                },
+                            },
+                        ],
+                        metadata: {
+                            resourceVersion: '216532459',
+                            continue: 'abc',
+                        },
+                    },
+                    contentTypeJsonHeader,
+                );
+            const lr = await client.list(
+                'v1',
+                'Secret',
+                undefined,
+                undefined,
+                undefined,
+                undefined,
+                'metadata.name=test-secret1',
+                'app=my-app',
+                5,
+            );
+            const items = lr.items;
+            expect(items).to.have.length(1);
+            scope.done();
+        });
+    });
+
+    describe('errors', () => {
+        let client: KubernetesObjectApi;
+        before(() => {
+            const kc = new KubeConfig();
+            kc.loadFromOptions(testConfigOptions);
+            client = KubernetesObjectApi.makeApiClient(kc);
+        });
+
+        it('should throw error if no spec', async () => {
+            const methods = [client.create, client.patch, client.read, client.replace, client.delete];
+            for (const s of [null, undefined]) {
+                for (const m of methods) {
+                    let thrown = false;
+                    try {
+                        // TODO: Figure out why Typescript barfs if we do m.call
+                        const hack_m = m as any;
+                        await hack_m.call(client, s);
+                        expect.fail('should have thrown an error');
+                    } catch (e) {
+                        thrown = true;
+                        if (e instanceof Error) {
+                            expect(e.message).to.contain(
+                                'Required parameter spec was null or undefined when calling ',
+                            );
+                        } else {
+                            fail(`unknown exception: ${e}`);
+                        }
+                    }
+                    expect(thrown).to.be.true;
+                }
+            }
+        });
+
+        it('should throw an error if request throws an error', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: 'valid-name',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            nock('https://d.i.y');
+            let thrown = false;
+            try {
+                await client.read(s);
+                expect.fail('should have thrown error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.contain('Nock: No match for request');
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+        });
+
+        it('should throw an error if name not valid', async () => {
+            const s = {
+                apiVersion: 'v1',
+                kind: 'Service',
+                metadata: {
+                    name: '_not_a_valid_name_',
+                    namespace: 'default',
+                },
+                spec: {
+                    ports: [
+                        {
+                            port: 80,
+                            protocol: 'TCP',
+                            targetPort: 80,
+                        },
+                    ],
+                    selector: {
+                        app: 'sleep',
+                    },
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/api/v1')
+                .reply(200, resourceBodies.core, contentTypeJsonHeader)
+                .post('/api/v1/namespaces/default/services', s)
+                .reply(
+                    422,
+                    `{
+  "kind": "Status",
+  "apiVersion": "v1",
+  "metadata": {},
+  "status": "Failure",
+  "message": "Service \"_not_a_valid_name_\" is invalid: metadata.name: Invalid value: \"_not_a_valid_name_\": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name',  or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')",
+  "reason": "Invalid",
+  "details": {
+    "name": "_not_a_valid_name_",
+    "kind": "Service",
+    "causes": [
+      {
+        "reason": "FieldValueInvalid",
+        "message": "Invalid value: \"_not_a_valid_name_\": a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name',  or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')",
+        "field": "metadata.name"
+      }
+    ]
+  },
+  "code": 422
+}`,
+                    contentTypeJsonHeader,
+                );
+            let thrown = false;
+            try {
+                await client.create(s);
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect((e as ApiException<string | Buffer | undefined>).code).to.equal(422);
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+            scope.done();
+        });
+
+        it('should throw an error if apiVersion not valid', async () => {
+            const d = {
+                apiVersion: 'applications/v1',
+                kind: 'Deployment',
+                metadata: {
+                    name: 'should-not-be-created',
+                    namespace: 'default',
+                },
+                spec: {
+                    selector: {
+                        matchLabels: {
+                            app: 'sleep',
+                        },
+                    },
+                    template: {
+                        metadata: {
+                            labels: {
+                                app: 'sleep',
+                            },
+                        },
+                        spec: {
+                            containers: [
+                                {
+                                    args: ['60'],
+                                    command: ['sleep'],
+                                    image: 'alpine',
+                                    name: 'sleep',
+                                    ports: [{ containerPort: 80 }],
+                                },
+                            ],
+                        },
+                    },
+                },
+            };
+            const scope = nock('https://d.i.y')
+                .get('/apis/applications/v1')
+                .reply(404, '{}', contentTypeJsonHeader);
+            let thrown = false;
+            try {
+                await client.create(d);
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect((e as ApiException<string | Buffer | undefined>).code).to.equal(404);
+                    expect(e.message).to.satisfy((m: string) =>
+                        m.startsWith('Failed to fetch resource metadata for applications/v1/Deployment'),
+                    );
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+            scope.done();
+        });
+
+        it('should throw error if no apiVersion', async () => {
+            let thrown = false;
+            try {
+                await (client.list as any)(undefined, undefined);
+                expect.fail('should have thrown an error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.contain(
+                        'Required parameter apiVersion was null or undefined when calling ',
+                    );
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+        });
+
+        it('should throw error if no kind', async () => {
+            let thrown = false;
+            try {
+                await (client.list as any)('', undefined);
+                expect.fail('should have thrown an error');
+            } catch (e) {
+                thrown = true;
+                if (e instanceof Error) {
+                    expect(e.message).to.contain(
+                        'Required parameter kind was null or undefined when calling ',
+                    );
+                } else {
+                    fail(`unknown exception: ${e}`);
+                }
+            }
+            expect(thrown).to.be.true;
+        });
+    });
+});