diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index 6a239c43ee..86d273cefd 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -1,13 +1,89 @@ /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested - * @param key The key to test if it is present on the object + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) */ -export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { +export function hasOwnProperty(val: S, key: K): val is (S & { [key in K]: unknown }) { // this call syntax is for when `val` was created by `Object.create(null)` return Object.prototype.hasOwnProperty.call(val, key); } -export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { +/** + * Narrows `val` to a static type that includes fields of names in `keys` + * @param val the value that we are trying to type narrow + * @param keys the key names (must be literals for tsc to do any type meaningful) + */ +export function hasOwnProperties(val: S, ...keys: K[]): val is (S & { [key in K]: unknown }) { return keys.every(key => hasOwnProperty(val, key)); } + +/** + * Narrows `val` to include the property `key` with type `V` + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) + * @param isValid a function to check if the field is valid + */ +export function hasTypedProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]: V }) { + return hasOwnProperty(val, key) && isValid(val[key]); +} + +/** + * Narrows `val` to include the property `key` with type `V | undefined` or doesn't contain it + * @param val the value that we are trying to type narrow + * @param key The key to test if it is present on the object (must be a literal for tsc to do any type meaningful) + * @param isValid a function to check if the field (when present) is valid + */ +export function hasOptionalProperty(val: S, key: K, isValid: (value: unknown) => value is V): val is (S & { [key in K]?: V }) { + if (hasOwnProperty(val, key)) { + return typeof val[key] === "undefined" || isValid(val[key]); + } + + return true; +} + +/** + * isRecord checks if `val` matches the signature `Record` or `{ [label in T]: V }` + * @param val The value to be checked + * @param isKey a function for checking if the key is of the correct type + * @param isValue a function for checking if a value is of the correct type + */ +export function isRecord(val: unknown, isKey: (key: unknown) => key is T, isValue: (value: unknown) => value is V): val is Record { + return isObject(val) && Object.entries(val).every(([key, value]) => isKey(key) && isValue(value)); +} + +/** + * isTypedArray checks if `val` is an array and all of its entries are of type `T` + * @param val The value to be checked + * @param isEntry a function for checking if an entry is the correct type + */ +export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry is T): val is T[] { + return Array.isArray(val) && val.every(isEntry); +} + +/** + * checks if val is of type string + * @param val the value to be checked + */ +export function isString(val: unknown): val is string { + return typeof val === "string"; +} + +/** + * checks if val is of type object and isn't null + * @param val the value to be checked + */ +export function isObject(val: unknown): val is object { + return typeof val === "object" && val !== null; +} + +/** + * Creates a new predicate function (with the same predicate) from `fn`. Such + * that it can be called with just the value to be tested. + * + * This is useful for when using `hasOptionalProperty` and `hasTypedProperty` + * @param fn A typescript user predicate function to be bound + * @param boundArgs the set of arguments to be passed to `fn` in the new function + */ +export function bindPredicate(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): (arg1: unknown) => arg1 is T { + return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); +} diff --git a/src/renderer/api/__tests__/kube-object.test.ts b/src/renderer/api/__tests__/kube-object.test.ts new file mode 100644 index 0000000000..b3f84ea1ae --- /dev/null +++ b/src/renderer/api/__tests__/kube-object.test.ts @@ -0,0 +1,228 @@ +import { KubeObject } from "../kube-object"; + +describe("KubeObject", () => { + describe("isJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["apiVersion", { kind: "", metadata: {uid: "", name: "", resourceVersion: "", selfLink: ""} }], + ["metadata", { kind: "", apiVersion: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: {} }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: {} }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1 } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1 } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1 } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiData (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isJsonApiData(valid)).toBe(true); + }); + }); + + describe("isPartialJsonApiData", () => { + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept {}", () => { + expect(KubeObject.isPartialJsonApiData({})).toBe(true); + }); + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "" }], + ]; + + it.each(tests)("should not reject with missing top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(true); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["metadata.uid", { kind: "", apiVersion: "", metadata: { name: "", resourceVersion: "", selfLink: ""} }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing non-top level field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["apiVersion", { apiVersion: 1, kind: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", apiVersion: "", metadata: "" }], + ["metadata.uid", { kind: "", apiVersion: "", metadata: { uid: 1, name: "", resourceVersion: "", selfLink: "" } }], + ["metadata.name", { kind: "", apiVersion: "", metadata: { uid: "", name: 1, resourceVersion: "", selfLink: "" } }], + ["metadata.resourceVersion", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: 1 } }], + ["metadata.namespace", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", namespace: 1 } }], + ["metadata.creationTimestamp", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", creationTimestamp: 1 } }], + ["metadata.continue", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", continue: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: 1 } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: [1] } }], + ["metadata.finalizers", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", finalizers: {} } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: 1 } }], + ["metadata.labels", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", labels: { food: 1 } } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: 1 } }], + ["metadata.annotations", { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: 1 } } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isPartialJsonApiData(input)).toBe(false); + }); + } + + it("should accept valid Partial (ignoring other fields)", () => { + const valid = { kind: "", apiVersion: "", metadata: { uid: "", name: "", resourceVersion: "", selfLink: "", annotations: { food: "" } } }; + + expect(KubeObject.isPartialJsonApiData(valid)).toBe(true); + }); + }); + + describe("isJsonApiDataList", () => { + function isAny(val: unknown): val is any { + return !Boolean(void val); + } + + function isNotAny(val: unknown): val is any { + return Boolean(void val); + } + + function isBoolean(val: unknown): val is Boolean { + return typeof val === "boolean"; + } + + { + type TestCase = [any]; + const tests: TestCase[] = [ + [false], + [true], + [null], + [undefined], + [""], + [1], + [(): unknown => void 0], + [Symbol("hello")], + [{}], + ]; + + it.each(tests)("should reject invalid value: %p", (input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { apiVersion: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "" }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { selfLink: "" } }], + ]; + + it.each(tests)("should reject with missing: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isAny)).toBe(false); + }); + } + + { + type TestCase = [string, any]; + const tests: TestCase[] = [ + ["kind", { kind: 1, items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["apiVersion", { kind: "", items: [], apiVersion: 1, metadata: { resourceVersion: "", selfLink: "" } }], + ["metadata", { kind: "", items: [], apiVersion: "", metadata: 1 }], + ["metadata.resourceVersion", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: 1, selfLink: "" } }], + ["metadata.selfLink", { kind: "", items: [], apiVersion: "", metadata: { resourceVersion: "", selfLink: 1 } }], + ["items", { kind: "", items: 1, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: "", apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items", { kind: "", items: {}, apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ["items[0]", { kind: "", items: [""], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }], + ]; + + it.each(tests)("should reject with wrong type for field: %s", (missingField, input) => { + expect(KubeObject.isJsonApiDataList(input, isNotAny)).toBe(false); + }); + } + + it("should accept valid KubeJsonApiDataList (ignoring other fields)", () => { + const valid = { kind: "", items: [false], apiVersion: "", metadata: { resourceVersion: "", selfLink: "" } }; + + expect(KubeObject.isJsonApiDataList(valid, isBoolean)).toBe(true); + }); + }); +}); diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index df12b08ab7..343aecd4c9 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -2,8 +2,8 @@ import { stringify } from "querystring"; import { EventEmitter } from "../../common/event-emitter"; -import { cancelableFetch } from "../utils/cancelableFetch"; import { randomBytes } from "crypto"; + export interface JsonApiData { } @@ -72,13 +72,11 @@ export class JsonApi { reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; } - const infoLog: JsonApiLog = { + this.writeLog({ method: reqInit.method.toUpperCase(), reqUrl: reqPath, reqInit, - }; - - this.writeLog({ ...infoLog }); + }); return fetch(reqUrl, reqInit); } @@ -99,7 +97,7 @@ export class JsonApi { return this.request(path, params, { ...reqInit, method: "delete" }); } - protected request(path: string, params?: P, init: RequestInit = {}) { + protected async request(path: string, params?: P, init: RequestInit = {}) { let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; @@ -119,48 +117,53 @@ export class JsonApi { reqInit, }; - return cancelableFetch(reqUrl, reqInit).then(res => { - return this.parseResponse(res, infoLog); - }); + const res = await fetch(reqUrl, reqInit); + + return this.parseResponse(res, infoLog); } - protected parseResponse(res: Response, log: JsonApiLog): Promise { + protected async parseResponse(res: Response, log: JsonApiLog): Promise { const { status } = res; - return res.text().then(text => { - let data; + const text = await res.text(); + let data; - try { - data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body - } catch (e) { - data = text; - } + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } - if (status >= 200 && status < 300) { - this.onData.emit(data, res); - this.writeLog({ ...log, data }); + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); - return data; - } else if (log.method === "GET" && res.status === 403) { - this.writeLog({ ...log, data }); - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + return data; + } - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; - } - }); + if (log.method === "GET" && res.status === 403) { + this.writeLog({ ...log, error: data }); + throw data; + } + + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + + throw error; } protected parseError(error: JsonApiError | string, res: Response): string[] { if (typeof error === "string") { return [error]; } - else if (Array.isArray(error.errors)) { + + if (Array.isArray(error.errors)) { return error.errors.map(error => error.title); } - else if (error.message) { + + if (error.message) { return [error.message]; } diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 448cd9da8f..7ff83f1ecf 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -7,11 +7,12 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; import { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; +import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; +import { noop } from "../utils"; export interface IKubeApiOptions { /** @@ -34,6 +35,11 @@ export interface IKubeApiOptions { checkPreferredVersion?: boolean; } +export interface KubeApiListOptions { + namespace?: string; + reqInit?: RequestInit; +} + export interface IKubeApiQueryParams { watch?: boolean | number; resourceVersion?: string; @@ -243,7 +249,7 @@ export class KubeApi { return this.resourceVersions.get(namespace); } - async refreshResourceVersion(params?: { namespace: string }) { + async refreshResourceVersion(params?: KubeApiListOptions) { return this.list(params, { limit: 1 }); } @@ -271,20 +277,12 @@ export class KubeApi { return query; } - protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + protected parseResponse(data: unknown, namespace?: string): T | T[] | null { if (!data) return; const KubeObjectConstructor = this.objectConstructor; - if (KubeObject.isJsonApiData(data)) { - const object = new KubeObjectConstructor(data); - - ensureObjectSelfLink(this, object); - - return object; - } - - // process items list response - if (KubeObject.isJsonApiDataList(data)) { + // process items list response, check before single item since there is overlap + if (KubeObject.isJsonApiDataList(data, KubeObject.isPartialJsonApiData)) { const { apiVersion, items, metadata } = data; this.setResourceVersion(namespace, metadata.resourceVersion); @@ -303,55 +301,90 @@ export class KubeApi { }); } + // process a single item + if (KubeObject.isJsonApiData(data)) { + const object = new KubeObjectConstructor(data); + + ensureObjectSelfLink(this, object); + + return object; + } + // custom apis might return array for list response, e.g. users, groups, etc. if (Array.isArray(data)) { return data.map(data => new KubeObjectConstructor(data)); } - return data; + return null; } - async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { + async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace }), { query }) - .then(data => this.parseResponse(data, namespace)); + const url = this.getUrl({ namespace }); + const res = await this.request.get(url, { query }, reqInit); + const parsed = this.parseResponse(res, namespace); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (!parsed) { + return null; + } + + throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } - async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { + async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - return this.request - .get(this.getUrl({ namespace, name }), { query }) - .then(this.parseResponse); + const url = this.getUrl({ namespace, name }); + const res = await this.request.get(url, { query }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); + const apiUrl = this.getUrl({ namespace }); + const res = await this.request.post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }); + const parsed = this.parseResponse(res); - return this.request - .post(apiUrl, { - data: merge({ - kind: this.kind, - apiVersion: this.apiVersionWithGroup, - metadata: { - name, - namespace - } - }, data) - }) - .then(this.parseResponse); + if (Array.isArray(parsed)) { + throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } - async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); const apiUrl = this.getUrl({ namespace, name }); - return this.request - .put(apiUrl, { data }) - .then(this.parseResponse); + const res = await this.request.put(apiUrl, { data }); + const parsed = this.parseResponse(res); + + if (Array.isArray(parsed)) { + throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); + } + + return parsed; } async delete({ name = "", namespace = "default" }) { @@ -370,78 +403,60 @@ export class KubeApi { } watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { - if (!opts.abortController) { - opts.abortController = new AbortController(); - } let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { abortController, namespace, callback } = opts; + const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts; - abortController.signal.addEventListener("abort", () => { + signal.addEventListener("abort", () => { clearTimeout(timedRetry); }); const watchUrl = this.getWatchUrl(namespace); - const responsePromise = this.request.getResponse(watchUrl, null, { - signal: abortController.signal - }); + const responsePromise = this.request.getResponse(watchUrl, null, { signal }); - responsePromise.then((response) => { - if (!response.ok && !abortController.signal.aborted) { - callback?.(null, response); - - return; - } - const nodeStream = new ReadableWebToNodeStream(response.body); - - ["end", "close", "error"].forEach((eventName) => { - nodeStream.on(eventName, () => { - if (errorReceived) return; // kubernetes errors should be handled in a callback - - clearTimeout(timedRetry); - timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry - if (abortController.signal.aborted) return; - - this.watch({...opts, namespace, callback}); - }, 1000); - }); - }); - - const stream = byline(nodeStream); - - stream.on("data", (line) => { - try { - const event: IKubeWatchEvent = JSON.parse(line); - - if (event.type === "ERROR" && event.object.kind === "Status") { - errorReceived = true; - callback(null, new KubeStatus(event.object as any)); - - return; - } - - this.modifyWatchEvent(event); - - if (callback) { - callback(event, null); - } - } catch (ignore) { - // ignore parse errors + responsePromise + .then(response => { + if (!response.ok) { + return callback(null, response); } + + const nodeStream = new ReadableWebToNodeStream(response.body); + + ["end", "close", "error"].forEach((eventName) => { + nodeStream.on(eventName, () => { + if (errorReceived) return; // kubernetes errors should be handled in a callback + + clearTimeout(timedRetry); + timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry + this.watch({...opts, namespace, callback}); + }, 1000); + }); + }); + + byline(nodeStream).on("data", (line) => { + try { + const event: IKubeWatchEvent = JSON.parse(line); + + if (event.type === "ERROR" && event.object.kind === "Status") { + errorReceived = true; + + return callback(null, new KubeStatus(event.object as any)); + } + + this.modifyWatchEvent(event); + callback(event, null); + } catch (ignore) { + // ignore parse errors + } + }); + }) + .catch(error => { + if (error instanceof DOMException) return; // AbortController rejects, we can ignore it + + callback(null, error); }); - }, (error) => { - if (error instanceof DOMException) return; // AbortController rejects, we can ignore it - callback?.(null, error); - }).catch((error) => { - callback?.(null, error); - }); - - const disposer = () => { - abortController.abort(); - }; - - return disposer; + return abort; } protected modifyWatchEvent(event: IKubeWatchEvent) { diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 362ee5438e..0dfe53c8d2 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -1,34 +1,38 @@ import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; +export interface KubeJsonApiListMetadata { + resourceVersion: string; + selfLink?: string; +} + export interface KubeJsonApiDataList { kind: string; apiVersion: string; items: T[]; - metadata: { - resourceVersion: string; - selfLink: string; + metadata: KubeJsonApiListMetadata; +} + +export interface KubeJsonApiMetadata { + uid: string; + name: string; + namespace?: string; + creationTimestamp?: string; + resourceVersion: string; + continue?: string; + finalizers?: string[]; + selfLink?: string; + labels?: { + [label: string]: string; + }; + annotations?: { + [annotation: string]: string; }; } export interface KubeJsonApiData extends JsonApiData { kind: string; apiVersion: string; - metadata: { - uid: string; - name: string; - namespace?: string; - creationTimestamp?: string; - resourceVersion: string; - continue?: string; - finalizers?: string[]; - selfLink?: string; - labels?: { - [label: string]: string; - }; - annotations?: { - [annotation: string]: string; - }; - }; + metadata: KubeJsonApiMetadata; } export interface KubeJsonApiError extends JsonApiError { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 7d0c34de33..8699a3c94f 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -1,12 +1,13 @@ // Base class for all kubernetes objects import moment from "moment"; -import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { KubeJsonApiData, KubeJsonApiDataList, KubeJsonApiListMetadata, KubeJsonApiMetadata } from "./kube-json-api"; import { autobind, formatDuration } from "../utils"; import { ItemObject } from "../item.store"; import { apiKube } from "./index"; import { JsonApiParams } from "./json-api"; import { resourceApplierApi } from "./endpoints/resource-applier.api"; +import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { kind?: string; @@ -78,15 +79,59 @@ export class KubeObject implements ItemObject { return !item.metadata.name.startsWith("system:"); } - static isJsonApiData(object: any): object is KubeJsonApiData { - return !object.items && object.metadata; + static isJsonApiData(object: unknown): object is KubeJsonApiData { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); } - static isJsonApiDataList(object: any): object is KubeJsonApiDataList { - return object.items && object.metadata; + static isKubeJsonApiListMetadata(object: unknown): object is KubeJsonApiListMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + ); } - static stringifyLabels(labels: { [name: string]: string }): string[] { + static isKubeJsonApiMetadata(object: unknown): object is KubeJsonApiMetadata { + return ( + isObject(object) + && hasTypedProperty(object, "uid", isString) + && hasTypedProperty(object, "name", isString) + && hasTypedProperty(object, "resourceVersion", isString) + && hasOptionalProperty(object, "selfLink", isString) + && hasOptionalProperty(object, "namespace", isString) + && hasOptionalProperty(object, "creationTimestamp", isString) + && hasOptionalProperty(object, "continue", isString) + && hasOptionalProperty(object, "finalizers", bindPredicate(isTypedArray, isString)) + && hasOptionalProperty(object, "labels", bindPredicate(isRecord, isString, isString)) + && hasOptionalProperty(object, "annotations", bindPredicate(isRecord, isString, isString)) + ); + } + + static isPartialJsonApiData(object: unknown): object is Partial { + return ( + isObject(object) + && hasOptionalProperty(object, "kind", isString) + && hasOptionalProperty(object, "apiVersion", isString) + && hasOptionalProperty(object, "metadata", KubeObject.isKubeJsonApiMetadata) + ); + } + + static isJsonApiDataList(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList { + return ( + isObject(object) + && hasTypedProperty(object, "kind", isString) + && hasTypedProperty(object, "apiVersion", isString) + && hasTypedProperty(object, "metadata", KubeObject.isKubeJsonApiListMetadata) + && hasTypedProperty(object, "items", bindPredicate(isTypedArray, verifyItem)) + ); + } + + static stringifyLabels(labels?: { [name: string]: string }): string[] { if (!labels) return []; return Object.entries(labels).map(([name, value]) => `${name}=${value}`);