react-native-cloud-storage
Version:
☁️ Save to & read from iCloud and Google Drive using React Native
250 lines (225 loc) • 8.29 kB
text/typescript
import { type CloudStorageProviderOptions, type DeepRequired } from '../../types/main';
import {
MimeTypes,
type GoogleDriveFile,
type GoogleDriveFileSpace,
type GoogleDriveListOperationQueryParameters,
type GoogleDriveListOperationResponse,
} from './types';
import { localFileSystem } from '../../utils/local-fs';
const BASE_URL = 'https://www.googleapis.com/drive/v3';
const BASE_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3';
const MULTIPART_BOUNDARY = 'foo_bar_baz';
export class GoogleDriveHttpError extends Error {
public status: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public json: any;
constructor(message: string, status: number, json: unknown) {
super(message);
this.status = status;
this.json = json;
}
}
// TODO: properly handle errors
export default class GoogleDriveApiClient {
private _fetchTimeout: NodeJS.Timeout | null = null;
private options: DeepRequired<CloudStorageProviderOptions['googledrive']>;
constructor(options: DeepRequired<CloudStorageProviderOptions['googledrive']>) {
this.options = options;
}
private buildQueryString(query: object): string {
let result = Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
if (typeof value === 'boolean') return `${encodeURIComponent(key)}=${value ? 'true' : 'false'}`;
return `${encodeURIComponent(key)}=${encodeURIComponent(value!)}`;
})
.join('&');
if (result) {
result = `?${result}`;
}
return result;
}
private async request<T extends object | string | void = void>(
operation: `/${string}`,
{ queryParameters, baseUrl, ...options }: RequestInit & { queryParameters?: object; baseUrl?: string } = {}
): Promise<T> {
const { timeout, accessToken } = this.options;
let path = `${baseUrl ?? BASE_URL}${operation}`;
if (queryParameters) {
path += this.buildQueryString(queryParameters);
}
if (this._fetchTimeout !== null) clearTimeout(this._fetchTimeout);
const abortController: AbortController = new AbortController();
this._fetchTimeout = setTimeout(() => {
abortController.abort();
}, timeout);
const response = await fetch(path, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage: string;
let json: unknown = null;
try {
json = await response.json();
errorMessage =
typeof json === 'object' &&
json !== null &&
'error' in json &&
typeof json.error === 'object' &&
json.error !== null &&
'message' in json.error &&
typeof json.error.message === 'string'
? json.error.message
: `Request failed with status ${response.status}`;
} catch {
errorMessage = `Request failed with status ${response.status}`;
}
throw new GoogleDriveHttpError(errorMessage, response.status, json);
}
if (response.status === 204) {
return undefined as unknown as T;
}
if (options?.headers && 'Accept' in options.headers && options.headers.Accept !== 'application/json') {
return response.text() as unknown as Promise<T>;
}
return response.json();
}
private buildMultiPartBody(metadata: object, media: { mimeType: string; body: string }): string {
const body: string[] = [];
body.push(
`--${MULTIPART_BOUNDARY}\r\n`,
`Content-Type: ${MimeTypes.JSON}; charset=UTF-8\r\n\r\n`,
`${JSON.stringify(metadata)}\r\n`,
`--${MULTIPART_BOUNDARY}\r\n`,
`Content-Type: ${media.mimeType}\r\n\r\n`,
`${media.body}\r\n`,
`--${MULTIPART_BOUNDARY}--`
);
return body.join('');
}
public async listFiles(space: GoogleDriveFileSpace, query?: string): Promise<GoogleDriveFile[]> {
const files: GoogleDriveFile[] = [];
let pageToken: string | undefined;
const fields = ['id', 'kind', 'mimeType', 'name', 'parents', 'spaces', 'size', 'createdTime', 'modifiedTime'];
do {
const queryParameters: GoogleDriveListOperationQueryParameters = {
fields: `files(${fields.join(',')}),nextPageToken`,
spaces: space,
pageToken,
q: query,
};
const response = await this.request<GoogleDriveListOperationResponse>(`/files`, {
queryParameters,
});
files.push(...response.files);
pageToken = response.nextPageToken ?? undefined;
} while (pageToken);
return files;
}
public async getFile(fileId: string): Promise<GoogleDriveFile> {
const queryParameters: GoogleDriveListOperationQueryParameters = {
fields: ['id', 'kind', 'mimeType', 'name', 'parents', 'spaces', 'size', 'createdTime', 'modifiedTime'].join(','),
};
return this.request<GoogleDriveFile>(`/files/${fileId}`, {
queryParameters,
});
}
public async getFileText(fileId: string): Promise<string> {
return this.request<string>(`/files/${fileId}`, {
queryParameters: { alt: 'media' },
headers: { Accept: 'text/plain' },
});
}
public async deleteFile(fileId: string): Promise<void> {
return this.request(`/files/${fileId}`, {
method: 'DELETE',
});
}
public async createFile(
metadata: { name: string; parents?: string[] },
media: { mimeType: string; body: string } | { mimeType: string; localPath: string }
): Promise<void> {
if ('body' in media) {
const multipartRequestBody = this.buildMultiPartBody(metadata, media);
await this.request(`/files`, {
queryParameters: { uploadType: 'multipart' },
method: 'POST',
headers: {
'Content-Type': `multipart/related; boundary=${MULTIPART_BOUNDARY}`,
'Content-Length': multipartRequestBody.length.toString(),
},
body: multipartRequestBody,
baseUrl: BASE_UPLOAD_URL,
});
} else {
// First, create an empty file with the required metadata
const file = await this.request<{ id: string }>(`/files`, {
method: 'POST',
headers: {
'Content-Type': MimeTypes.JSON,
},
body: JSON.stringify({ ...metadata, mimeType: media.mimeType }),
});
// Then, upload the binary content via the native helper module
const remoteUri = `${BASE_UPLOAD_URL}/files/${file.id}?uploadType=media`;
await localFileSystem.uploadFile(media.localPath, remoteUri, {
method: 'PATCH',
uploadType: 'binary',
headers: {
'Authorization': `Bearer ${this.options.accessToken}`,
'Content-Type': media.mimeType,
},
});
}
}
public async createDirectory(metadata: { name: string; parents?: string[] }): Promise<void> {
await this.request(`/files`, {
method: 'POST',
headers: {
'Content-Type': `application/json`,
},
body: JSON.stringify({ ...metadata, mimeType: MimeTypes.FOLDER }),
});
}
public async updateFile(
fileId: string,
media: { mimeType: string; body: string } | { mimeType: string; localPath: string }
): Promise<void> {
if ('body' in media) {
await this.request(`/files/${fileId}`, {
queryParameters: { uploadType: 'media' },
method: 'PATCH',
headers: {
'Content-Type': media.mimeType,
'Content-Length': media.body.length.toString(),
},
body: media.body,
baseUrl: BASE_UPLOAD_URL,
});
} else {
const remoteUri = `${BASE_UPLOAD_URL}/files/${fileId}?uploadType=media`;
await localFileSystem.uploadFile(media.localPath, remoteUri, {
method: 'PATCH',
uploadType: 'binary',
headers: {
'Authorization': `Bearer ${this.options.accessToken}`,
'Content-Type': media.mimeType,
},
});
}
}
public async downloadFile(fileId: string, localPath: string): Promise<void> {
const remoteUri = `${BASE_URL}/files/${fileId}?alt=media`;
await localFileSystem.downloadFile(remoteUri, localPath, {
headers: {
Authorization: `Bearer ${this.options.accessToken}`,
},
});
}
}