From 7be415bb8f92df73b0a0cdee849009adb2b756a7 Mon Sep 17 00:00:00 2001 From: roman Date: Sun, 30 Aug 2026 16:45:59 +0200 Subject: [PATCH] refactor(base): migrate Base from Flow to TypeScript --- src/api/{Base.js => Base.js.flow} | 11 +- src/api/Base.ts | 274 ++++++++++++++++++ .../__tests__/{Base.test.js => Base.test.ts} | 85 +++--- 3 files changed, 324 insertions(+), 46 deletions(-) rename src/api/{Base.js => Base.js.flow} (96%) create mode 100644 src/api/Base.ts rename src/api/__tests__/{Base.test.js => Base.test.ts} (81%) diff --git a/src/api/Base.js b/src/api/Base.js.flow similarity index 96% rename from src/api/Base.js rename to src/api/Base.js.flow index 976a4ff431..7051d9e950 100644 --- a/src/api/Base.js +++ b/src/api/Base.js.flow @@ -46,8 +46,8 @@ class Base { * Optional regional metadata host. * * When set and distinct from `apiHost`, subclasses (currently `Metadata`) - * route metadata *instance* endpoints to this host while keeping - * templates, taxonomies, suggestions, options, and queries on `apiHost`. + * route metadata *instance* and *template* endpoints to this host while + * keeping taxonomies, suggestions, options, and queries on `apiHost`. * Empty values, or values equal to `apiHost`, are treated as "not set" * and resolve to the same URLs as `apiHost` alone. * @@ -108,9 +108,10 @@ class Base { * @param {string} [options.sharedLinkPassword] - Shared link password * @param {string} [options.apiHost] - Api host * @param {string} [options.metadataApiHost] - Regional metadata API host - * used for metadata *instance* endpoints. Templates, taxonomies, - * suggestions, options, and queries continue to use `apiHost`. Falls - * back to `apiHost` when undefined, empty, or equal to `apiHost`. + * used for metadata *instance* and *template* endpoints when configured. + * Taxonomies, suggestions, options, and queries continue to use + * `apiHost`. Falls back to `apiHost` when undefined, empty, or equal to + * `apiHost`. * @param {string} [options.uploadHost] - Upload host name * @return {Base} Base instance */ diff --git a/src/api/Base.ts b/src/api/Base.ts new file mode 100644 index 0000000000..46e1caf81e --- /dev/null +++ b/src/api/Base.ts @@ -0,0 +1,274 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Preserve explicit Flow `any` contracts. */ +import type { AxiosError } from 'axios'; +import noop from 'lodash/noop'; +import Xhr from '../utils/Xhr'; +import Cache from '../utils/Cache'; +import UploadsReachability from './uploads/UploadsReachability'; +import { getTypedFileId } from '../utils/file'; +import { getBadItemError, getBadPermissionsError } from '../utils/error'; +import { + DEFAULT_HOSTNAME_API, + DEFAULT_HOSTNAME_UPLOAD, + HTTP_GET, + HTTP_POST, + HTTP_PUT, + HTTP_DELETE, +} from '../constants'; +import type { ElementsErrorCallback, APIOptions } from '../common/types/api'; +import type APICache from '../utils/Cache'; + +type SuccessCallback = (data?: object) => void; + +interface DeleteRequest { + /** Optional request body sent with the DELETE call. */ + data?: object; + /** Invoked when the request fails. */ + errorCallback: ElementsErrorCallback; + /** Box item id passed through to the XHR layer for auth and typing. */ + id: string; + /** Invoked with the response payload when the request succeeds. */ + successCallback: Function; + /** Full URL of the resource to delete. */ + url: string; +} + +interface GetRequest { + /** Invoked when the request fails. */ + errorCallback: ElementsErrorCallback; + /** Box item id; used for auth and to resolve the URL when `url` is omitted. */ + id: string; + /** Extra fields merged into the XHR request (e.g. query params); GET uses `requestData`, not `data`. */ + requestData?: object; + /** Invoked with the response payload when the request succeeds. */ + successCallback: Function; + /** API URL; when omitted, `getUrl(id)` is used. */ + url?: string; +} + +interface WriteRequest { + /** JSON request body for POST or PUT. */ + data: object; + /** Invoked when the request fails. */ + errorCallback: ElementsErrorCallback; + /** Box item id passed through to the XHR layer for auth and typing. */ + id: string; + /** Invoked with the response payload when the request succeeds. */ + successCallback: Function; + /** Full URL to post or put to. */ + url: string; +} + +class Base { + /** In-memory cache for API responses, shared via `options.cache`. */ + cache: APICache; + + /** True after `destroy()` has aborted in-flight requests. */ + destroyed: boolean; + + /** HTTP client used for GET/POST/PUT/DELETE against Box APIs. */ + xhr: Xhr; + + /** API hostname used to build `/2.0` URLs (default global Box API host). */ + apiHost: string; + + /** + * Optional regional metadata host. + * + * When set and distinct from `apiHost`, subclasses (currently `Metadata`) + * route metadata *instance* and *template* endpoints to this host while + * keeping taxonomies, suggestions, options, and queries on `apiHost`. + * Empty values, or values equal to `apiHost`, are treated as "not set" + * and resolve to the same URLs as `apiHost` alone. + * + * Transitional: this field exists to support the `metadataApiHost` + * option while the global `apiHost` is not yet regionalized end-to-end. + * It is expected to be retired in a future major version. + */ + metadataApiHost: string | undefined; + + /** Upload API hostname used to build upload `/api/2.0` URLs. */ + uploadHost: string; + + /** Normalized constructor options (hosts, cache, token, etc.) passed to `Xhr`. */ + options: APIOptions; + + /** `console.log` when `consoleLog` is enabled in options; otherwise `noop`. */ + consoleLog: Function; + + /** `console.error` when `consoleError` is enabled in options; otherwise `noop`. */ + consoleError: Function; + + /** Elements error code; subclasses set this before requests for `errorHandler`. */ + errorCode: string; + + /** Success handler for the in-flight `makeRequest` call; set per request. */ + successCallback: SuccessCallback; + + /** Error handler for the in-flight `makeRequest` call; set per request. */ + errorCallback: ElementsErrorCallback; + + /** Helper for probing upload endpoint reachability. */ + uploadsReachability: UploadsReachability; + + /** Falls back to the default Box API and upload hosts and a new `Cache` when not provided. */ + constructor(options: APIOptions) { + this.cache = options.cache || new Cache(); + this.apiHost = options.apiHost || DEFAULT_HOSTNAME_API; + this.metadataApiHost = options.metadataApiHost; + this.uploadHost = options.uploadHost || DEFAULT_HOSTNAME_UPLOAD; + // @TODO: avoid keeping another copy of data in this.options + this.options = { + ...options, + apiHost: this.apiHost, + metadataApiHost: this.metadataApiHost, + uploadHost: this.uploadHost, + cache: this.cache, + }; + this.xhr = new Xhr(this.options); + this.destroyed = false; + this.consoleLog = !!options.consoleLog && !!window.console ? window.console.log || noop : noop; + this.consoleError = !!options.consoleError && !!window.console ? window.console.error || noop : noop; + this.uploadsReachability = new UploadsReachability(); + } + + /** Aborts in-flight XHR and marks this instance destroyed. */ + destroy(): void { + this.xhr.abort(); + this.destroyed = true; + } + + /** Whether `destroy()` has been called on this instance. */ + isDestroyed(): boolean { + return this.destroyed; + } + + /** Throws if `id` or `permissions` is missing, or if `permissions[permissionToCheck]` is falsy. */ + checkApiCallValidity(permissionToCheck: string, permissions?: Record, id?: string): void { + if (!id || !permissions) { + throw getBadItemError(); + } + + const permission = permissions[permissionToCheck]; + if (!permission) { + throw getBadPermissionsError(); + } + } + + /** + * Builds an API base URL for an arbitrary host, appending the API + * version suffix (`/2.0`) and tolerating a trailing slash on the host. + * + * Shared helper used by `getBaseApiUrl()` and by subclasses that need + * to derive a `/2.0` URL from a host other than `this.apiHost` (e.g. + * `Metadata` when `metadataApiHost` is configured). + */ + buildApiUrl(host: string): string { + const suffix: string = host.endsWith('/') ? '2.0' : '/2.0'; + return `${host}${suffix}`; + } + + /** Base URL for the configured `apiHost` (`…/2.0`). */ + getBaseApiUrl(): string { + return this.buildApiUrl(this.apiHost); + } + + /** Base URL for the configured `uploadHost` (`…/api/2.0`). */ + getBaseUploadUrl(): string { + const suffix: string = this.uploadHost.endsWith('/') ? 'api/2.0' : '/api/2.0'; + return `${this.uploadHost}${suffix}`; + } + + /** Returns the in-memory cache used by this API instance. */ + getCache(): APICache { + return this.cache; + } + + /** Invokes the current `successCallback` with response data when the instance is not destroyed. */ + successHandler = (data: any): void => { + if (!this.isDestroyed() && typeof this.successCallback === 'function') { + this.successCallback(data); + } + }; + + /** Invokes the current `errorCallback` with response data or the raw error when not destroyed. */ + errorHandler = (error: AxiosError): void => { + if (!this.isDestroyed() && typeof this.errorCallback === 'function') { + const { response } = error; + + if (response?.data) { + this.errorCallback(response.data, this.errorCode); + } else { + this.errorCallback(error, this.errorCode); + } + } + }; + + /** Gets the URL for the API, meant to be overridden by subclasses. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subclasses implement this method using the item ID. + getUrl(id: string): string { + // TODO: Implement this method + throw new Error('Implement me!'); + } + + /** Formats an API entry for use in components, meant to be overridden by subclasses. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subclasses implement this method using the API entry. + format(entry: object): any { + // TODO: Implement this method + throw new Error('Implement me!'); + } + + /** Issues a GET request; uses `requestData` (not `data`) for extra XHR fields */ + get({ id, successCallback, errorCallback, requestData, url }: GetRequest): Promise { + const apiUrl = url || this.getUrl(id); + return this.makeRequest(HTTP_GET, id, apiUrl, successCallback, errorCallback, requestData); + } + + /** Issues a POST with a JSON body. */ + post({ id, url, data, successCallback, errorCallback }: WriteRequest): Promise { + return this.makeRequest(HTTP_POST, id, url, successCallback, errorCallback, data); + } + + /** Issues a PUT with a JSON body. */ + put({ id, url, data, successCallback, errorCallback }: WriteRequest): Promise { + return this.makeRequest(HTTP_PUT, id, url, successCallback, errorCallback, data); + } + + /** Issues a DELETE, optionally with a request body. */ + delete({ id, url, data, successCallback, errorCallback }: DeleteRequest): Promise { + return this.makeRequest(HTTP_DELETE, id, url, successCallback, errorCallback, data); + } + + /** + * Runs an XHR verb against `url`, merges `requestData` into the request payload, + * and routes success or failure through the instance handlers. + */ + async makeRequest( + method: string, + id: string, + url: string, + successCallback: Function, + errorCallback: ElementsErrorCallback, + requestData: object = {}, + ): Promise { + if (this.isDestroyed()) { + return; + } + + this.successCallback = successCallback as SuccessCallback; + this.errorCallback = errorCallback; + + const xhrMethod: (request: object) => Promise<{ data: any }> = this.xhr[method.toLowerCase()].bind(this.xhr); + try { + const { data } = await xhrMethod({ + id: getTypedFileId(id), + url, + ...requestData, + }); + this.successHandler(data); + } catch (error) { + this.errorHandler(error); + } + } +} + +export default Base; diff --git a/src/api/__tests__/Base.test.js b/src/api/__tests__/Base.test.ts similarity index 81% rename from src/api/__tests__/Base.test.js rename to src/api/__tests__/Base.test.ts index d114ef1011..bf38b8cd8e 100644 --- a/src/api/__tests__/Base.test.js +++ b/src/api/__tests__/Base.test.ts @@ -4,18 +4,21 @@ import { getBadItemError, getBadPermissionsError } from '../../utils/error'; import Base from '../Base'; import { HTTP_GET, HTTP_POST, HTTP_PUT } from '../../constants'; -let base; +type BaseError = Parameters[0]; + +let base: Base; describe('api/Base', () => { const baseResponse = { total_count: 0, entries: [] }; const errorCode = 'foo'; + const token = 'token'; beforeEach(() => { - base = new Base({}); + base = new Base({ token }); base.errorCode = errorCode; }); - test('should should have correct defaults on construct', () => { + test('should have correct defaults on construct', () => { expect(base.options.apiHost).toBe('https://api.box.com'); expect(base.options.uploadHost).toBe('https://upload.box.com'); expect(base.cache).toBeInstanceOf(Cache); @@ -25,15 +28,17 @@ describe('api/Base', () => { expect(base.destroyed).toBeFalsy(); }); - test('should should have correct values on construct', () => { + test('should have correct values on construct', () => { + const cache = new Cache(); const options = { - cache: 'cache', + cache, apiHost: 'apiHost', uploadHost: 'uploadHost', + token, }; base = new Base(options); expect(base.options).toEqual(options); - expect(base.cache).toBe('cache'); + expect(base.cache).toBe(cache); expect(base.apiHost).toBe('apiHost'); expect(base.uploadHost).toBe('uploadHost'); expect(base.xhr).toBeInstanceOf(Xhr); @@ -42,7 +47,7 @@ describe('api/Base', () => { describe('destroy()', () => { beforeEach(() => { - base.xhr = { abort: jest.fn() }; + base.xhr = { abort: jest.fn() } as unknown as Xhr; }); test('should return false when no destroyed', () => { @@ -59,38 +64,34 @@ describe('api/Base', () => { describe('checkApiCallValidity()', () => { const badItemError = getBadItemError(); const permissionsError = getBadPermissionsError(); + test('should throw a bad item error for a missing file ID or permissions object', () => { - try { - base.checkApiCallValidity('can_edit', undefined, 'id'); - } catch (error) { - expect(error.message).toBe(badItemError.message); - } - - try { - base.checkApiCallValidity('can_edit', { permissions: { can_edit: false } }, null); - } catch (error) { - expect(error.message).toBe(badItemError.message); - } - - try { - base.checkApiCallValidity('can_edit', { permissions: {} }, 'id'); - } catch (error) { - expect(error.message).toBe(permissionsError.message); - } + expect(() => base.checkApiCallValidity('can_edit', undefined, 'id')).toThrow(badItemError.message); + expect(() => base.checkApiCallValidity('can_edit', { can_edit: false }, null)).toThrow( + badItemError.message, + ); + }); + + test('should throw a bad permissions error if the given permission is missing or false', () => { + expect(() => base.checkApiCallValidity('can_edit', {}, 'id')).toThrow(permissionsError.message); + expect(() => base.checkApiCallValidity('can_edit', { can_edit: false }, 'id')).toThrow( + permissionsError.message, + ); }); - test('should throw a bad permissions error if the given permission is missing or false', () => {}); }); describe('getBaseApiUrl()', () => { test('should return correct api url', () => { base = new Base({ apiHost: 'apiHost', + token, }); expect(base.getBaseApiUrl()).toBe('apiHost/2.0'); }); test('should return correct api url with trailing /', () => { base = new Base({ apiHost: 'apiHost/', + token, }); expect(base.getBaseApiUrl()).toBe('apiHost/2.0'); }); @@ -100,12 +101,14 @@ describe('api/Base', () => { test('should return correct api upload url', () => { base = new Base({ uploadHost: 'uploadHost', + token, }); expect(base.getBaseUploadUrl()).toBe('uploadHost/api/2.0'); }); test('should return correct api upload url with trailing /', () => { base = new Base({ uploadHost: 'uploadHost/', + token, }); expect(base.getBaseUploadUrl()).toBe('uploadHost/api/2.0'); }); @@ -113,7 +116,7 @@ describe('api/Base', () => { describe('getCache()', () => { test('should return correct cache', () => { - base.cache = 'foo'; + base.cache = 'foo' as unknown as Cache; expect(base.getCache()).toBe('foo'); }); }); @@ -122,12 +125,12 @@ describe('api/Base', () => { beforeEach(() => { base.errorCallback = jest.fn(); base.isDestroyed = jest.fn().mockReturnValue(false); - base.errorCode = 1; + base.errorCode = '1'; }); test('should do nothing if destroyed', () => { base.isDestroyed = jest.fn().mockReturnValueOnce(true); - base.errorHandler(new Error()); + base.errorHandler(new Error() as BaseError); expect(base.errorCallback).not.toBeCalled(); }); test('should call the error callback with the response data if present', () => { @@ -138,8 +141,8 @@ describe('api/Base', () => { }, }; - base.errorHandler(error); - expect(base.errorCallback).toBeCalledWith('foo', 1); + base.errorHandler(error as unknown as BaseError); + expect(base.errorCallback).toBeCalledWith('foo', '1'); }); test('should call the error callback with the whole error if the response data is not present', () => { base.errorCallback = jest.fn(); @@ -149,8 +152,8 @@ describe('api/Base', () => { }, }; - base.errorHandler(error); - expect(base.errorCallback).toBeCalledWith(error, 1); + base.errorHandler(error as unknown as BaseError); + expect(base.errorCallback).toBeCalledWith(error, '1'); }); }); @@ -180,23 +183,23 @@ describe('api/Base', () => { describe('makeRequest()', () => { const url = 'https://foo.bar'; - test('should not do anything if destroyed', () => { + test('should not do anything if destroyed', async () => { base.isDestroyed = jest.fn().mockReturnValueOnce(true); base.xhr = null; const successCb = jest.fn(); const errorCb = jest.fn(); - return base.makeRequest(HTTP_GET, 'id', url, successCb, errorCb).catch(() => { - expect(successCb).not.toHaveBeenCalled(); - expect(errorCb).not.toHaveBeenCalled(); - }); + await base.makeRequest(HTTP_GET, 'id', url, successCb, errorCb); + + expect(successCb).not.toHaveBeenCalled(); + expect(errorCb).not.toHaveBeenCalled(); }); test('should make xhr to get base and call success callback', () => { base.xhr = { post: jest.fn().mockReturnValueOnce(Promise.resolve({ data: baseResponse })), - }; + } as unknown as Xhr; const successCb = jest.fn(); const errorCb = jest.fn(); @@ -213,7 +216,7 @@ describe('api/Base', () => { test('should forward write body and query params to xhr as separate fields', () => { base.xhr = { post: jest.fn().mockReturnValueOnce(Promise.resolve({ data: baseResponse })), - }; + } as unknown as Xhr; const requestData = { data: { message: 'hello' }, params: { enable_rich_text: true }, @@ -233,7 +236,7 @@ describe('api/Base', () => { const error = new Error('error'); base.xhr = { put: jest.fn().mockReturnValueOnce(Promise.reject(error)), - }; + } as unknown as Xhr; const successCb = jest.fn(); const errorCb = jest.fn(); @@ -260,7 +263,7 @@ describe('api/Base', () => { }; base.xhr = { post: jest.fn().mockReturnValueOnce(Promise.resolve({ data: baseResponse })), - }; + } as unknown as Xhr; const successCb = jest.fn(); const errorCb = jest.fn();