From 76fce00306cff43d4267f10dc49d04b531a20029 Mon Sep 17 00:00:00 2001 From: Eric BREHAULT Date: Wed, 10 Jan 2024 17:42:18 +0100 Subject: [PATCH] dropbox implementation --- .../connectors/dropbox.connector.ts | 246 ++++++++++++++++++ .../tests/dropbox.connector.spec.js | 124 +++++++++ .../logic/connector/infrastructure/factory.ts | 2 + 3 files changed, 372 insertions(+) create mode 100644 electron-app/src/logic/connector/infrastructure/connectors/dropbox.connector.ts create mode 100644 electron-app/src/logic/connector/infrastructure/connectors/tests/dropbox.connector.spec.js diff --git a/electron-app/src/logic/connector/infrastructure/connectors/dropbox.connector.ts b/electron-app/src/logic/connector/infrastructure/connectors/dropbox.connector.ts new file mode 100644 index 0000000..fa0e0a8 --- /dev/null +++ b/electron-app/src/logic/connector/infrastructure/connectors/dropbox.connector.ts @@ -0,0 +1,246 @@ +import { Observable, catchError, concatMap, forkJoin, from, map, of } from 'rxjs'; + +import { ConnectorParameters, FileStatus, IConnector, Link, SearchResults, SyncItem } from '../../domain/connector'; +import { SourceConnectorDefinition } from '../factory'; +import { OAuthBaseConnector } from './oauth.base'; + +export const DropboxConnector: SourceConnectorDefinition = { + id: 'dropbox', + factory: () => new DropboxImpl(), +}; + +export class DropboxImpl extends OAuthBaseConnector implements IConnector { + params: ConnectorParameters = {}; + isExternal = false; + + hasAuthData() { + return !!this.params.token; + } + + setParameters(params: ConnectorParameters) { + this.params = params; + } + + getParameters(): ConnectorParameters { + return this.params; + } + + areParametersValid(params: ConnectorParameters) { + if (!params?.token) { + return false; + } + if (!params?.refresh) { + return false; + } + return true; + } + + getLastModified(since: string, folders?: SyncItem[] | undefined): Observable { + if ((folders ?? []).length === 0) { + return of({ + items: [], + }); + } + try { + return forkJoin((folders || []).map((folder) => this._getFiles('', false, folder.originalId))).pipe( + map((results) => { + const items = results.reduce( + (acc, result) => acc.concat(result.items.filter((item) => item.modifiedGMT && item.modifiedGMT > since)), + [] as SyncItem[], + ); + return { + items, + }; + }), + ); + } catch (err) { + return of({ + items: [], + }); + } + } + + getFilesFromFolders(folders: SyncItem[]): Observable { + if ((folders ?? []).length === 0) { + return of({ + items: [], + }); + } + try { + return forkJoin((folders || []).map((folder) => this._getFiles('', false, folder.originalId))).pipe( + map((results) => { + const result: { items: SyncItem[] } = { + items: [], + }; + results.forEach((res) => { + result.items = [...result.items, ...res.items]; + }); + return result; + }), + ); + } catch (err) { + return of({ + items: [], + }); + } + } + + getFolders(query?: string | undefined): Observable { + return this._getFiles(query, true, ''); + } + + getFiles(query?: string): Observable { + return this._getFiles(query); + } + + isAccesTokenValid(): Observable { + return from( + fetch('https://api.dropboxapi.com/2/users/get_current_account ', { + method: 'POST', + headers: { + Authorization: `Bearer ${this.params.token || ''}`, + }, + }).then( + (res) => res.json(), + (err) => { + console.error(`Error fetching about: ${err}`); + throw new Error(err); + }, + ), + ).pipe( + concatMap((res) => { + if (res.error && res.error['.tag'] === 'invalid_access_token') { + return of(false); + } + return of(true); + }), + catchError(() => { + return of(true); + }), + ); + } + + private _getFiles( + query?: string, + loadFolders = false, + path = '', + nextPage?: string | number, + previous?: SearchResults, + ): Observable { + const success = (url: string) => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return (res: any) => { + if (res.status === 200) { + return res.json(); + } else if (res.status === 401) { + console.error(`Unauthorized for ${url}`); + throw new Error('Unauthorized'); + } else { + console.error(`Error for ${url}`); + return res.text().then((text: string) => { + throw new Error(text || 'Unknown error'); + }); + } + }; + }; + const failure = (url: string) => { + return (err: any) => { + console.error(`Error for ${url}: ${err}`); + throw new Error(); + }; + }; + const url = query + ? `https://api.dropboxapi.com/2/files/search_v2${nextPage ? '/continue' : ''}` + : `https://api.dropboxapi.com/2/files/list_folder${nextPage ? '/continue' : ''}`; + const params = query ? { query } : { path, recursive: true, limit: 100, include_media_info: true }; + const request = fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.params.token || ''}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(nextPage ? { cursor: nextPage } : params), + }).then(success(url), failure(url)); + return from(request).pipe( + concatMap((result: any) => { + const newItems = + (query + ? result.matches?.filter((item: any) => this.filterResults(item, loadFolders)).map(this.mapResults) + : result.entries?.filter((item: any) => this.filterFiles(item, loadFolders)).map(this.mapFiles)) || []; + const items = [...(previous?.items || []), ...newItems]; + return result.has_more + ? this._getFiles(query, loadFolders, path, result.cursor, { items, nextPage: result.cursor }) + : of({ items }); + }), + ); + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + private mapFiles(raw: any): SyncItem { + const isFolder = raw['.tag'] === 'folder'; + return { + title: raw.name || '', + originalId: (isFolder ? raw.path_lower : raw.id) || '', + metadata: {}, + status: FileStatus.PENDING, + uuid: raw.uuid || '', + modifiedGMT: raw.client_modified, + isFolder, + }; + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + private mapResults(raw: any): SyncItem { + return { + title: raw.metadata?.metadata?.['name'] || '', + originalId: raw.metadata?.metadata?.['id'] || '', + metadata: {}, + status: FileStatus.PENDING, + uuid: raw.metadata?.metadata?.['uuid'] || '', + isFolder: raw.match_type?.['.tag'] === 'folder', + }; + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + private filterFiles(raw: any, folders = false): boolean { + return folders ? raw?.['.tag'] === 'folder' : raw?.['.tag'] !== 'folder'; + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + private filterResults(raw: any, folders = false): boolean { + return folders ? raw.match_type?.['.tag'] === 'folder' : raw.match_type?.['.tag'] !== 'folder'; + } + + getLink(): Observable { + throw new Error('Method not implemented.'); + } + + download(resource: SyncItem): Observable { + try { + return new Observable((observer) => { + fetch('https://content.dropboxapi.com/2/files/download', { + method: 'POST', + headers: { + Authorization: `Bearer ${this.params.token || ''}`, + 'Dropbox-API-Arg': JSON.stringify({ path: resource.originalId }), + }, + }) + .then((res) => res.blob()) + .then( + (blob) => { + observer.next(blob); + observer.complete(); + }, + (e) => { + console.error(e); + observer.next(undefined); + observer.complete(); + }, + ); + }); + } catch (e) { + console.error(e); + return of(undefined); + } + } +} diff --git a/electron-app/src/logic/connector/infrastructure/connectors/tests/dropbox.connector.spec.js b/electron-app/src/logic/connector/infrastructure/connectors/tests/dropbox.connector.spec.js new file mode 100644 index 0000000..2dd4f94 --- /dev/null +++ b/electron-app/src/logic/connector/infrastructure/connectors/tests/dropbox.connector.spec.js @@ -0,0 +1,124 @@ +import { firstValueFrom, of } from 'rxjs'; +import { describe, expect, test, vi } from 'vitest'; +import { FileStatus } from '../../../domain/connector'; +import { DropboxImpl } from '../dropbox.connector'; + +const data = [ + { + uuid: '1v8WV_aNM5qB_642saVlPhOkN1xI0NtQo', + title: 'PO6300590983', + originalId: '1v8WV_aNM5qB_642saVlPhOkN1xI0NtQo', + modifiedGMT: '2023-11-29T12:49:27.539Z', + metadata: {}, + status: 'PENDING', + }, + { + uuid: '19QJOCaOY4R8EQZ7VDrmmu2FBkeOlRAxJ', + title: 'PO6300604892', + originalId: '19QJOCaOY4R8EQZ7VDrmmu2FBkeOlRAxJ', + modifiedGMT: '2023-11-27T12:48:06.061Z', + metadata: {}, + status: 'PENDING', + }, + { + uuid: '1-5mIXJuiLTFxTO4mmVdXGNdf-Da-EzgA', + title: 'PO4550970006', + originalId: '1-5mIXJuiLTFxTO4mmVdXGNdf-Da-EzgA', + modifiedGMT: '2023-11-27T12:46:08.712Z', + metadata: {}, + status: 'PENDING', + }, +]; + +const dropboxTest = test.extend({ + // eslint-disable-next-line no-empty-pattern + sourceConnector: async ({}, use) => { + const mock = vi.spyOn(DropboxImpl.prototype, '_getFiles').mockImplementation(() => { + return of({ + items: data, + nextPage: null, + }); + }); + await use(new DropboxImpl()); + mock.mockRestore(); + }, +}); + +describe('Test validate dropbox params', () => { + dropboxTest('Incorrect - Without params', ({ sourceConnector }) => { + expect(sourceConnector.areParametersValid({})).toBe(false); + }); + + dropboxTest('Incorrect - With wrong params', ({ sourceConnector }) => { + expect( + sourceConnector.areParametersValid({ + incorrect: 'test', + }), + ).toBe(false); + }); + + dropboxTest('Incorrect - With wrong params - one valid', ({ sourceConnector }) => { + expect( + sourceConnector.areParametersValid({ + incorrect: 'test', + token: 'test', + }), + ).toBe(false); + }); + + dropboxTest('Incorrect - With wrong params - one valid', ({ sourceConnector }) => { + expect( + sourceConnector.areParametersValid({ + incorrect: 'test', + refresh: 'test', + }), + ).toBe(false); + }); + + dropboxTest('Incorrect - With empty params', ({ sourceConnector }) => { + expect( + sourceConnector.areParametersValid({ + token: '', + refresh: '', + }), + ).toBe(false); + }); + + dropboxTest('Correct - With correct params', ({ sourceConnector }) => { + expect( + sourceConnector.areParametersValid({ + token: 'test', + refresh: 'test', + }), + ).toBe(true); + }); +}); + +describe('Test last modified', () => { + dropboxTest('Get last modified', async ({ sourceConnector }) => { + const lastModified = await firstValueFrom( + sourceConnector.getLastModified('2023-11-28T00:00:00.000Z', [ + { + uuid: 'test_uuid', + title: 'Test folder', + originalId: 'test_uuid', + metadata: {}, + status: FileStatus.PENDING, + }, + ]), + ); + + expect(lastModified).toEqual({ + items: [ + { + uuid: '1v8WV_aNM5qB_642saVlPhOkN1xI0NtQo', + title: 'PO6300590983', + originalId: '1v8WV_aNM5qB_642saVlPhOkN1xI0NtQo', + modifiedGMT: '2023-11-29T12:49:27.539Z', + metadata: {}, + status: FileStatus.PENDING, + }, + ], + }); + }); +}); diff --git a/electron-app/src/logic/connector/infrastructure/factory.ts b/electron-app/src/logic/connector/infrastructure/factory.ts index d9ab50c..95f8fcd 100644 --- a/electron-app/src/logic/connector/infrastructure/factory.ts +++ b/electron-app/src/logic/connector/infrastructure/factory.ts @@ -1,4 +1,5 @@ import { IConnector } from '../domain/connector'; +import { DropboxConnector } from './connectors/dropbox.connector'; import { FolderConnector } from './connectors/folder.connector'; import { GDriveConnector } from './connectors/gdrive.connector'; @@ -17,6 +18,7 @@ export type CONNECTORS_NAMES = 'folder' | 'gdrive'; const connectors: { [id: string]: SourceConnectorDefinition } = { folder: FolderConnector, gdrive: GDriveConnector, + dropbox: DropboxConnector, }; // TODO: add the dynamic connectors