Skip to content

Commit

Permalink
feat: added list() and list exists()
Browse files Browse the repository at this point in the history
  • Loading branch information
dereekb committed Jul 4, 2022
1 parent deeaa02 commit 388c593
Show file tree
Hide file tree
Showing 9 changed files with 501 additions and 23 deletions.
96 changes: 89 additions & 7 deletions packages/firebase-server/src/lib/storage/driver.accessor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { StorageUploadOptions, StorageServerUploadInput, FirebaseStorageAccessorDriver, FirebaseStorageAccessorFile, FirebaseStorageAccessorFolder, FirebaseStorage, StoragePath, assertStorageUploadOptionsStringFormat, StorageDeleteFileOptions } from '@dereekb/firebase';
import { Maybe, PromiseOrValue } from '@dereekb/util';
import { SaveOptions, CreateWriteStreamOptions, Storage as GoogleCloudStorage, File as GoogleCloudFile, DownloadOptions } from '@google-cloud/storage';
import { StorageUploadOptions, StorageServerUploadInput, FirebaseStorageAccessorDriver, FirebaseStorageAccessorFile, FirebaseStorageAccessorFolder, FirebaseStorage, StoragePath, assertStorageUploadOptionsStringFormat, StorageDeleteFileOptions, StorageListFilesOptions, storageListFilesResultFactory, StorageListItemResult, StorageListFilesResult, StorageMetadata, StorageBucketId } from '@dereekb/firebase';
import { fixMultiSlashesInSlashPath, ISO8601DateString, Maybe, PromiseOrValue, SlashPathFolder, slashPathName, SLASH_PATH_SEPARATOR, toRelativeSlashPathStartType, useCallback } from '@dereekb/util';
import { SaveOptions, CreateWriteStreamOptions, GetFilesOptions, Storage as GoogleCloudStorage, File as GoogleCloudFile, Bucket as GoogleCloudBucket, DownloadOptions } from '@google-cloud/storage';
import { isArrayBuffer, isUint8Array } from 'util/types';

export function googleCloudStorageBucketForStorageFilePath(storage: GoogleCloudStorage, path: StoragePath) {
return storage.bucket(path.bucketId);
}

export function googleCloudStorageFileForStorageFilePath(storage: GoogleCloudStorage, path: StoragePath) {
return storage.bucket(path.bucketId).file(path.pathString);
return googleCloudStorageBucketForStorageFilePath(storage, path).file(path.pathString);
}

export interface GoogleCloudStorageAccessorFile extends FirebaseStorageAccessorFile<GoogleCloudFile> {}
Expand Down Expand Up @@ -86,13 +90,91 @@ export function googleCloudStorageAccessorFile(storage: GoogleCloudStorage, stor

export interface GoogleCloudStorageAccessorFolder extends FirebaseStorageAccessorFolder<GoogleCloudFile> {}

export interface GoogleCloudListResult {
files: GoogleCloudFile[];
nextQuery?: GetFilesOptions;
apiResponse: GoogleCloudStorageListApiResponse;
}

export interface GoogleCloudStorageListApiResponse {
prefixes?: SlashPathFolder[];
items?: GoogleCloudStorageListApiResponseItem[];
}

export interface GoogleCloudStorageListApiResponseItem extends Pick<StorageMetadata, 'size' | 'generation' | 'metageneration' | 'contentDisposition' | 'contentType' | 'timeCreated' | 'updated' | 'contentEncoding' | 'md5Hash' | 'cacheControl'> {
kind: '#storage#object';
/**
* For the api response, the name is actually the full path in the bucket.
*/
name: string;
bucket: StorageBucketId;
}

export const googleCloudStorageListFilesResultFactory = storageListFilesResultFactory({
hasItems(result: GoogleCloudListResult): boolean {
return Boolean(result.apiResponse.items || result.apiResponse.prefixes);
},
hasNext: (result: GoogleCloudListResult) => {
return result.nextQuery != null;
},
next(storage: GoogleCloudStorage, folder: FirebaseStorageAccessorFolder, result: GoogleCloudListResult): Promise<StorageListFilesResult> {
return folder.list(result.nextQuery);
},
file(storage: GoogleCloudStorage, fileResult: StorageListItemResult): FirebaseStorageAccessorFile {
return googleCloudStorageAccessorFile(storage, fileResult.storagePath);
},
folder(storage: GoogleCloudStorage, folderResult: StorageListItemResult): FirebaseStorageAccessorFolder {
return googleCloudStorageAccessorFolder(storage, folderResult.storagePath);
},
filesFromResult(result: GoogleCloudListResult): StorageListItemResult[] {
const items = result.apiResponse?.items ?? [];
return items.map((x) => ({ raw: x, name: slashPathName(x.name), storagePath: { bucketId: x.bucket, pathString: x.name } }));
},
foldersFromResult(result: GoogleCloudListResult, folder: FirebaseStorageAccessorFolder): StorageListItemResult[] {
const items = result.apiResponse?.prefixes ?? [];
return items.map((prefix) => ({ raw: prefix, name: slashPathName(prefix), storagePath: { bucketId: folder.storagePath.bucketId, pathString: prefix } }));
}
});

export function googleCloudStorageAccessorFolder(storage: GoogleCloudStorage, storagePath: StoragePath): GoogleCloudStorageAccessorFolder {
const file = googleCloudStorageFileForStorageFilePath(storage, storagePath);
const bucket = googleCloudStorageBucketForStorageFilePath(storage, storagePath);
const file = bucket.file(storagePath.pathString);

return {
const folder: GoogleCloudStorageAccessorFolder = {
reference: file,
storagePath
storagePath,
exists: async () => folder.list({ maxResults: 1 }).then((x) => x.hasItems()),
list: (options?: StorageListFilesOptions) => {
return new Promise((resolve, reject) => {
bucket.getFiles(
{
...options,
delimiter: SLASH_PATH_SEPARATOR,
autoPaginate: false,
versions: false,
maxResults: options?.maxResults,
// includeTrailingDelimiter: true,
prefix: toRelativeSlashPathStartType(fixMultiSlashesInSlashPath(storagePath.pathString + '/')) // make sure the folder always ends with a slash
},
(err: Error | null, files?: GoogleCloudFile[], nextQuery?: GetFilesOptions, apiResponse?: GoogleCloudStorageListApiResponse) => {
if (err) {
reject(err);
} else {
const result: GoogleCloudListResult = {
files: files as GoogleCloudFile[],
nextQuery,
apiResponse: apiResponse as object
};

resolve(googleCloudStorageListFilesResultFactory(storage, folder, options, result));
}
}
);
});
}
};

return folder;
}

export function googleCloudStorageFirebaseStorageAccessorDriver(): FirebaseStorageAccessorDriver {
Expand Down
60 changes: 49 additions & 11 deletions packages/firebase/src/lib/client/storage/driver.accessor.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { FirebaseStorageAccessorDriver, FirebaseStorageAccessorFile, FirebaseStorageAccessorFolder } from '../../common/storage/driver/accessor';
import { StorageReference, getDownloadURL, FirebaseStorage as ClientFirebaseStorage, ref } from '@firebase/storage';
import { FirebaseStorageAccessorDriver, FirebaseStorageAccessorFile, FirebaseStorageAccessorFolder, StorageListFilesOptions, StorageListFilesResult, StorageListItemResult } from '../../common/storage/driver/accessor';
import { firebaseStorageFilePathFromStorageFilePath, StoragePath } from '../../common/storage/storage';
import { FirebaseStorage, StorageClientUploadBytesInput, StorageClientUploadInput, StorageDataString, StorageDeleteFileOptions, StorageUploadOptions } from '../../common/storage/types';
import { getBytes, getMetadata, uploadBytes, uploadBytesResumable, UploadMetadata, uploadString, deleteObject, getBlob } from 'firebase/storage';
import { assertStorageUploadOptionsStringFormat } from '../../common';
import { ListResult, list, StorageReference, getDownloadURL, FirebaseStorage as ClientFirebaseStorage, ref, getBytes, getMetadata, uploadBytes, uploadBytesResumable, UploadMetadata, uploadString, deleteObject, getBlob } from '@firebase/storage';
import { assertStorageUploadOptionsStringFormat, storageListFilesResultFactory } from '../../common';
import { ErrorInput, errorMessageContainsString, Maybe } from '@dereekb/util';

export function isFirebaseStorageObjectNotFoundError(input: Maybe<ErrorInput | string>): boolean {
Expand All @@ -14,6 +13,13 @@ export function firebaseStorageRefForStorageFilePath(storage: ClientFirebaseStor
return ref(storage, firebaseStorageFilePathFromStorageFilePath(path));
}

export function firebaseStorageFileExists(ref: StorageReference): Promise<boolean> {
return getMetadata(ref).then(
(_) => true,
(_) => false
);
}

export interface FirebaseStorageClientAccessorFile extends FirebaseStorageAccessorFile<StorageReference> {}

export function firebaseStorageClientAccessorFile(storage: ClientFirebaseStorage, storagePath: StoragePath): FirebaseStorageClientAccessorFile {
Expand All @@ -39,11 +45,7 @@ export function firebaseStorageClientAccessorFile(storage: ClientFirebaseStorage
return {
reference: ref,
storagePath,
exists: () =>
getMetadata(ref).then(
(_) => true,
(_) => false
),
exists: () => firebaseStorageFileExists(ref),
getDownloadUrl: () => getDownloadURL(ref),
getMetadata: () => getMetadata(ref),
upload: (input, options) => {
Expand Down Expand Up @@ -74,13 +76,49 @@ export function firebaseStorageClientAccessorFile(storage: ClientFirebaseStorage

export interface FirebaseStorageClientAccessorFolder extends FirebaseStorageAccessorFolder<StorageReference> {}

export interface FirebaseStorageClientListResult {
listResult: ListResult;
options?: StorageListFilesOptions;
}

export const firebaseStorageClientListFilesResultFactory = storageListFilesResultFactory({
hasItems: (result: FirebaseStorageClientListResult) => {
return Boolean(result.listResult.items.length || result.listResult.prefixes.length);
},
hasNext: (result: FirebaseStorageClientListResult) => {
return result.listResult.nextPageToken != null;
},
next(storage: ClientFirebaseStorage, folder: FirebaseStorageAccessorFolder, result: FirebaseStorageClientListResult): Promise<StorageListFilesResult> {
return folder.list({
...result.options,
pageToken: result.listResult.nextPageToken
});
},
file(storage: ClientFirebaseStorage, fileResult: StorageListItemResult): FirebaseStorageAccessorFile {
return firebaseStorageClientAccessorFile(storage, fileResult.storagePath);
},
folder(storage: ClientFirebaseStorage, folderResult: StorageListItemResult): FirebaseStorageAccessorFolder {
return firebaseStorageClientAccessorFolder(storage, folderResult.storagePath);
},
filesFromResult(result: FirebaseStorageClientListResult): StorageListItemResult[] {
return result.listResult.items.map((y) => ({ name: y.name, storagePath: { bucketId: y.bucket, pathString: y.fullPath } }));
},
foldersFromResult(result: FirebaseStorageClientListResult): StorageListItemResult[] {
return result.listResult.prefixes.map((y) => ({ name: y.name, storagePath: { bucketId: y.bucket, pathString: y.fullPath } }));
}
});

export function firebaseStorageClientAccessorFolder(storage: ClientFirebaseStorage, storagePath: StoragePath): FirebaseStorageClientAccessorFolder {
const ref = firebaseStorageRefForStorageFilePath(storage, storagePath);

return {
const folder: FirebaseStorageClientAccessorFolder = {
reference: ref,
storagePath
storagePath,
exists: () => folder.list({ maxResults: 1 }).then((x) => x.hasItems()),
list: (options?: StorageListFilesOptions) => list(ref, options).then((listResult) => firebaseStorageClientListFilesResultFactory(storage, folder, options, { options, listResult }))
};

return folder;
}

export function firebaseStorageClientAccessorDriver(): FirebaseStorageAccessorDriver {
Expand Down
3 changes: 3 additions & 0 deletions packages/firebase/src/lib/common/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface FirebaseStorageContext<F extends FirebaseStorage = FirebaseStor
*/
export type FirebaseStorageContextFactory<F extends FirebaseStorage = FirebaseStorage> = (firebaseStorage: F, config?: FirebaseStorageContextFactoryConfig) => FirebaseStorageContext;

/**
* firebaseStorageContextFactory() configuration
*/
export interface FirebaseStorageContextFactoryConfig {
/**
* The default bucket
Expand Down
79 changes: 78 additions & 1 deletion packages/firebase/src/lib/common/storage/driver/accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,89 @@ export interface FirebaseStorageAccessorFile<R extends unknown = unknown> extend
delete(options?: StorageDeleteFileOptions): Promise<void>;
}

export interface StorageListFilesOptions {
/**
* If set, limits the total number of `prefixes` and `items` to return.
* The default and maximum maxResults is 1000.
*/
maxResults?: number;
/**
* The `nextPageToken` from a previous call to `list()`. If provided,
* listing is resumed from the previous position.
*/
pageToken?: string;
}

export interface StorageListItemResult extends StoragePathRef {
/**
* Raw result
*/
readonly raw?: unknown;
/**
* Name of the item
*/
readonly name: string;
}

export interface StorageListFolderResult extends StorageListItemResult {
/**
* Gets this item as a FirebaseStorageAccessorFolder
*/
folder(): FirebaseStorageAccessorFolder;
}

export interface StorageListFileResult extends StorageListItemResult {
/**
* Gets this item as a FirebaseStorageAccessorFile
*/
file(): FirebaseStorageAccessorFile;
}

export interface StorageListFilesResult<R = unknown> {
/**
* The raw result.
*/
raw: R;
/**
* Options used to retrieve the result.
*/
options: StorageListFilesOptions | undefined;
/**
* Whether or not there are more results available.
*/
hasNext: boolean;
/**
* Returns true if any files or folders exist in the results.
*/
hasItems(): boolean;
/**
* Returns all the prefixes/folders in the result.
*/
folders(): StorageListFolderResult[];
/**
* Returns all the files in the result.
*/
files(): StorageListFileResult[];
/**
* Returns the next set of results, if available.
*/
next(): Promise<StorageListFilesResult>;
}

/**
* Generic interface for accessing "folder" information at the given path.
*/
export interface FirebaseStorageAccessorFolder<R extends unknown = unknown> extends StoragePathRef {
readonly reference: R;
// todo: list files, etc.
/**
* Returns true if the folder exists.
*/
exists(): Promise<boolean>;
/**
* Performs a search for items
* @param options
*/
list(options?: StorageListFilesOptions): Promise<StorageListFilesResult>;
}

export type FirebaseStorageAccessorDriverDefaultBucketFunction = (storage: FirebaseStorage) => Maybe<StorageBucketId>;
Expand Down
1 change: 1 addition & 0 deletions packages/firebase/src/lib/common/storage/driver/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './accessor';
export * from './driver';
export * from './error';
export * from './list';
56 changes: 56 additions & 0 deletions packages/firebase/src/lib/common/storage/driver/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { cachedGetter } from '@dereekb/util';
import { FirebaseStorageAccessorFile, FirebaseStorageAccessorFolder, StorageListFileResult, StorageListFilesOptions, StorageListFilesResult, StorageListFolderResult, StorageListItemResult } from './accessor';

export interface StorageListFilesResultFactoryDelegate<S, R> {
hasItems(result: R): boolean;
hasNext(result: R): boolean;
next(storage: S, folder: FirebaseStorageAccessorFolder, result: R): Promise<StorageListFilesResult>;
file(storage: S, fileResult: StorageListItemResult): FirebaseStorageAccessorFile;
folder(storage: S, folderResult: StorageListItemResult): FirebaseStorageAccessorFolder;
filesFromResult(result: R, folder: FirebaseStorageAccessorFolder): StorageListItemResult[];
foldersFromResult(result: R, folder: FirebaseStorageAccessorFolder): StorageListItemResult[];
}

export type StorageListFilesResultFactory<S, R> = (storage: S, folder: FirebaseStorageAccessorFolder, options: StorageListFilesOptions | undefined, result: R) => StorageListFilesResult;

export function storageListFilesResultFactory<S, R>(delegate: StorageListFilesResultFactoryDelegate<S, R>): StorageListFilesResultFactory<S, R> {
return (storage: S, folder: FirebaseStorageAccessorFolder, options: StorageListFilesOptions | undefined, result: R) => {
function fileResult(item: StorageListItemResult): StorageListFileResult {
(item as StorageListFileResult).file = () => delegate.file(storage, item);
return item as StorageListFileResult;
}

function folderResult(item: StorageListItemResult): StorageListFolderResult {
(item as StorageListFolderResult).folder = () => delegate.folder(storage, item);
return item as StorageListFolderResult;
}

const hasNext = delegate.hasNext(result);

const next: () => Promise<StorageListFilesResult> = cachedGetter(() => {
if (!hasNext) {
throw storageListFilesResultHasNoNextError();
}

return delegate.next(storage, folder, result);
});
const files: () => StorageListFileResult[] = cachedGetter(() => delegate.filesFromResult(result, folder).map(fileResult));
const folders: () => StorageListFolderResult[] = cachedGetter(() => delegate.foldersFromResult(result, folder).map(folderResult));

const filesResult: StorageListFilesResult = {
raw: result,
options,
hasNext,
hasItems: () => delegate.hasItems(result),
next,
files,
folders
};

return filesResult;
};
}

export function storageListFilesResultHasNoNextError() {
return new Error('hasNext is false, there are no more results available.');
}
Loading

0 comments on commit 388c593

Please sign in to comment.