diff --git a/.changeset/clever-eggs-relate.md b/.changeset/clever-eggs-relate.md new file mode 100644 index 00000000000..472cff21374 --- /dev/null +++ b/.changeset/clever-eggs-relate.md @@ -0,0 +1,6 @@ +--- +"@firebase/storage": minor +"firebase": minor +--- + +Adds `getBytes()`, `getStream()` and `getBlob()`, which allow direct file downloads from the SDK. diff --git a/common/api-review/storage.api.md b/common/api-review/storage.api.md index cbc4486e0c8..b893b9f256d 100644 --- a/common/api-review/storage.api.md +++ b/common/api-review/storage.api.md @@ -77,14 +77,15 @@ export class _FirebaseStorageImpl implements FirebaseStorage { _getAuthToken(): Promise; get host(): string; set host(host: string); + // Warning: (ae-forgotten-export) The symbol "ConnectionType" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "RequestInfo" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Connection" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Request" needs to be exported by the entry point index.d.ts // // (undocumented) - _makeRequest(requestInfo: RequestInfo_2, requestFactory: () => Connection, authToken: string | null, appCheckToken: string | null): Request_2; + _makeRequest(requestInfo: RequestInfo_2, requestFactory: () => Connection, authToken: string | null, appCheckToken: string | null): Request_2; // (undocumented) - makeRequestWithTokens(requestInfo: RequestInfo_2, requestFactory: () => Connection): Promise; + makeRequestWithTokens(requestInfo: RequestInfo_2, requestFactory: () => Connection): Promise; _makeStorageReference(loc: _Location): _Reference; get maxOperationRetryTime(): number; set maxOperationRetryTime(time: number); @@ -112,6 +113,12 @@ export interface FullMetadata extends UploadMetadata { updated: string; } +// @public +export function getBlob(ref: StorageReference, maxDownloadSizeBytes?: number): Promise; + +// @public +export function getBytes(ref: StorageReference, maxDownloadSizeBytes?: number): Promise; + // @internal (undocumented) export function _getChild(ref: StorageReference, childPath: string): _Reference; @@ -124,6 +131,9 @@ export function getMetadata(ref: StorageReference): Promise; // @public export function getStorage(app?: FirebaseApp, bucketUrl?: string): FirebaseStorage; +// @public +export function getStream(ref: StorageReference, maxDownloadSizeBytes?: number): NodeJS.ReadableStream; + // Warning: (ae-forgotten-export) The symbol "StorageError" needs to be exported by the entry point index.d.ts // // @internal (undocumented) diff --git a/packages/storage/.run/All Tests.run.xml b/packages/storage/.run/All Tests.run.xml index c74dd195587..1830936c7e7 100644 --- a/packages/storage/.run/All Tests.run.xml +++ b/packages/storage/.run/All Tests.run.xml @@ -11,9 +11,9 @@ bdd - --require ts-node/register/type-check --require index.node.ts + --require ts-node/register/type-check --require src/index.node.ts PATTERN test/{,!(browser)/**/}*.test.ts - \ No newline at end of file + diff --git a/packages/storage/src/api.browser.ts b/packages/storage/src/api.browser.ts index acb501f62d5..0ccf31bbdfb 100644 --- a/packages/storage/src/api.browser.ts +++ b/packages/storage/src/api.browser.ts @@ -16,6 +16,8 @@ */ import { StorageReference } from './public-types'; +import { Reference, getBlobInternal } from './reference'; +import { getModularInstance } from '@firebase/util'; /** * Downloads the data at the object's location. Returns an error if the object @@ -28,17 +30,17 @@ import { StorageReference } from './public-types'; * This API is not available in Node. * * @public - * @param ref - StorageReference where data should be download. + * @param ref - StorageReference where data should be downloaded. * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to * retrieve. * @returns A Promise that resolves with a Blob containing the object's bytes */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBlob( +export function getBlob( ref: StorageReference, maxDownloadSizeBytes?: number ): Promise { - throw new Error('Not implemented'); + ref = getModularInstance(ref); + return getBlobInternal(ref as Reference, maxDownloadSizeBytes); } /** @@ -48,19 +50,14 @@ function getBlob( * This API is only available in Node. * * @public - * @param ref - StorageReference where data should be download. + * @param ref - StorageReference where data should be downloaded. * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to * retrieve. * @returns A stream with the object's data as bytes */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getStream( +export function getStream( ref: StorageReference, maxDownloadSizeBytes?: number ): NodeJS.ReadableStream { throw new Error('getStream() is only supported by NodeJS builds'); } - -// TODO(getbytes): Export getBlob/getStream - -export {}; diff --git a/packages/storage/src/api.node.ts b/packages/storage/src/api.node.ts index 4ab8ef2e412..790147d26fa 100644 --- a/packages/storage/src/api.node.ts +++ b/packages/storage/src/api.node.ts @@ -16,6 +16,8 @@ */ import { StorageReference } from './public-types'; +import { Reference, getStreamInternal } from './reference'; +import { getModularInstance } from '@firebase/util'; /** * Downloads the data at the object's location. Returns an error if the object @@ -28,13 +30,13 @@ import { StorageReference } from './public-types'; * This API is not available in Node. * * @public - * @param ref - StorageReference where data should be download. + * @param ref - StorageReference where data should be downloaded. * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to * retrieve. * @returns A Promise that resolves with a Blob containing the object's bytes */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBlob( +export function getBlob( ref: StorageReference, maxDownloadSizeBytes?: number ): Promise { @@ -48,19 +50,15 @@ function getBlob( * This API is only available in Node. * * @public - * @param ref - StorageReference where data should be download. + * @param ref - StorageReference where data should be downloaded. * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to * retrieve. * @returns A stream with the object's data as bytes */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getStream( +export function getStream( ref: StorageReference, maxDownloadSizeBytes?: number ): NodeJS.ReadableStream { - throw new Error('Not implemented'); + ref = getModularInstance(ref); + return getStreamInternal(ref as Reference, maxDownloadSizeBytes); } - -// TODO(getbytes): Export getBlob/getStream - -export {}; diff --git a/packages/storage/src/api.ts b/packages/storage/src/api.ts index ad662a1011c..a489cacccb8 100644 --- a/packages/storage/src/api.ts +++ b/packages/storage/src/api.ts @@ -47,7 +47,8 @@ import { getDownloadURL as getDownloadURLInternal, deleteObject as deleteObjectInternal, Reference, - _getChild as _getChildInternal + _getChild as _getChildInternal, + getBytesInternal } from './reference'; import { STORAGE_TYPE } from './constants'; import { EmulatorMockTokenOptions, getModularInstance } from '@firebase/util'; @@ -76,6 +77,28 @@ export { } from './implementation/taskenums'; export { StringFormat }; +/** + * Downloads the data at the object's location. Returns an error if the object + * is not found. + * + * To use this functionality, you have to whitelist your app's origin in your + * Cloud Storage bucket. See also + * https://cloud.google.com/storage/docs/configuring-cors + * + * @public + * @param ref - StorageReference where data should be downloaded. + * @param maxDownloadSizeBytes - If set, the maximum allowed size in bytes to + * retrieve. + * @returns A Promise containing the object's bytes + */ +export function getBytes( + ref: StorageReference, + maxDownloadSizeBytes?: number +): Promise { + ref = getModularInstance(ref); + return getBytesInternal(ref as Reference, maxDownloadSizeBytes); +} + /** * Uploads data to this object's location. * The upload is not resumable. diff --git a/packages/storage/src/implementation/connection.ts b/packages/storage/src/implementation/connection.ts index ba8c2c3edb6..440a6966939 100644 --- a/packages/storage/src/implementation/connection.ts +++ b/packages/storage/src/implementation/connection.ts @@ -18,14 +18,27 @@ /** Network headers */ export type Headers = Record; +/** Response type exposed by the networking APIs. */ +export type ConnectionType = + | string + | ArrayBuffer + | Blob + | NodeJS.ReadableStream; + /** * A lightweight wrapper around XMLHttpRequest with a * goog.net.XhrIo-like interface. + * + * You can create a new connection by invoking `newTextConnection()`, + * `newBytesConnection()` or `newStreamConnection()`. */ -export interface Connection { +export interface Connection { /** - * This method should never reject. In case of encountering an error, set an error code internally which can be accessed - * by calling getErrorCode() by callers. + * Sends a request to the provided URL. + * + * This method never rejects its promise. In case of encountering an error, + * it sets an error code internally which can be accessed by calling + * getErrorCode() by callers. */ send( url: string, @@ -38,7 +51,9 @@ export interface Connection { getStatus(): number; - getResponseText(): string; + getResponse(): T; + + getErrorText(): string; /** * Abort the request. diff --git a/packages/storage/src/implementation/request.ts b/packages/storage/src/implementation/request.ts index 7eaf3f04439..226b1ea08de 100644 --- a/packages/storage/src/implementation/request.ts +++ b/packages/storage/src/implementation/request.ts @@ -20,18 +20,12 @@ * abstract representations. */ -import { start, stop, id as backoffId } from './backoff'; -import { - StorageError, - unknown, - appDeleted, - canceled, - retryLimitExceeded -} from './error'; -import { RequestHandler, RequestInfo } from './requestinfo'; +import { id as backoffId, start, stop } from './backoff'; +import { appDeleted, canceled, retryLimitExceeded, unknown } from './error'; +import { ErrorHandler, RequestHandler, RequestInfo } from './requestinfo'; import { isJustDef } from './type'; import { makeQueryString } from './url'; -import { Headers, Connection, ErrorCode } from './connection'; +import { Connection, ErrorCode, Headers, ConnectionType } from './connection'; export interface Request { getPromise(): Promise; @@ -46,15 +40,23 @@ export interface Request { cancel(appDelete?: boolean): void; } -class NetworkRequest implements Request { - private pendingConnection_: Connection | null = null; +/** + * Handles network logic for all Storage Requests, including error reporting and + * retries with backoff. + * + * @param I - the type of the backend's network response. + * @param - O the output type used by the rest of the SDK. The conversion + * happens in the specified `callback_`. + */ +class NetworkRequest implements Request { + private pendingConnection_: Connection | null = null; private backoffId_: backoffId | null = null; - private resolve_!: (value?: T | PromiseLike) => void; + private resolve_!: (value?: O | PromiseLike) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any private reject_!: (reason?: any) => void; private canceled_: boolean = false; private appDelete_: boolean = false; - promise_: Promise; + private promise_: Promise; constructor( private url_: string, @@ -63,14 +65,14 @@ class NetworkRequest implements Request { private body_: string | Blob | Uint8Array | null, private successCodes_: number[], private additionalRetryCodes_: number[], - private callback_: RequestHandler, - private errorCallback_: RequestHandler | null, + private callback_: RequestHandler, + private errorCallback_: ErrorHandler | null, private timeout_: number, private progressCallback_: ((p1: number, p2: number) => void) | null, - private connectionFactory_: () => Connection + private connectionFactory_: () => Connection ) { this.promise_ = new Promise((resolve, reject) => { - this.resolve_ = resolve as (value?: T | PromiseLike) => void; + this.resolve_ = resolve as (value?: O | PromiseLike) => void; this.reject_ = reject; this.start_(); }); @@ -135,17 +137,14 @@ class NetworkRequest implements Request { */ const backoffDone: ( requestWentThrough: boolean, - status: RequestEndStatus + status: RequestEndStatus ) => void = (requestWentThrough, status) => { const resolve = this.resolve_; const reject = this.reject_; - const connection = status.connection as Connection; + const connection = status.connection as Connection; if (status.wasSuccessCode) { try { - const result = this.callback_( - connection, - connection.getResponseText() - ); + const result = this.callback_(connection, connection.getResponse()); if (isJustDef(result)) { resolve(result); } else { @@ -157,7 +156,7 @@ class NetworkRequest implements Request { } else { if (connection !== null) { const err = unknown(); - err.serverResponse = connection.getResponseText(); + err.serverResponse = connection.getErrorText(); if (this.errorCallback_) { reject(this.errorCallback_(connection, err)); } else { @@ -182,7 +181,7 @@ class NetworkRequest implements Request { } /** @inheritDoc */ - getPromise(): Promise { + getPromise(): Promise { return this.promise_; } @@ -219,7 +218,7 @@ class NetworkRequest implements Request { * A collection of information about the result of a network request. * @param opt_canceled - Defaults to false. */ -export class RequestEndStatus { +export class RequestEndStatus { /** * True if the request was canceled. */ @@ -227,7 +226,7 @@ export class RequestEndStatus { constructor( public wasSuccessCode: boolean, - public connection: Connection | null, + public connection: Connection | null, canceled?: boolean ) { this.canceled = !!canceled; @@ -266,14 +265,14 @@ export function addAppCheckHeader_( } } -export function makeRequest( - requestInfo: RequestInfo, +export function makeRequest( + requestInfo: RequestInfo, appId: string | null, authToken: string | null, appCheckToken: string | null, - requestFactory: () => Connection, + requestFactory: () => Connection, firebaseVersion?: string -): Request { +): Request { const queryPart = makeQueryString(requestInfo.urlParams); const url = requestInfo.url + queryPart; const headers = Object.assign({}, requestInfo.headers); @@ -281,7 +280,7 @@ export function makeRequest( addAuthHeader_(headers, authToken); addVersionHeader_(headers, firebaseVersion); addAppCheckHeader_(headers, appCheckToken); - return new NetworkRequest( + return new NetworkRequest( url, requestInfo.method, headers, diff --git a/packages/storage/src/implementation/requestinfo.ts b/packages/storage/src/implementation/requestinfo.ts index 2451410f826..18af96d708f 100644 --- a/packages/storage/src/implementation/requestinfo.ts +++ b/packages/storage/src/implementation/requestinfo.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import { StorageError } from './error'; -import { Headers, Connection } from './connection'; +import { Headers, Connection, ConnectionType } from './connection'; /** * Type for url params stored in RequestInfo. @@ -31,13 +31,28 @@ export interface UrlParams { * @param I - the type of the backend's network response * @param O - the output response type used by the rest of the SDK. */ -export type RequestHandler = (connection: Connection, response: I) => O; +export type RequestHandler = ( + connection: Connection, + response: I +) => O; -export class RequestInfo { +/** A function to handle an error. */ +export type ErrorHandler = ( + connection: Connection, + response: StorageError +) => StorageError; + +/** + * Contains a fully specified request. + * + * @param I - the type of the backend's network response. + * @param O - the output response type used by the rest of the SDK. + */ +export class RequestInfo { urlParams: UrlParams = {}; headers: Headers = {}; body: Blob | string | Uint8Array | null = null; - errorHandler: RequestHandler | null = null; + errorHandler: ErrorHandler | null = null; /** * Called with the current number of bytes uploaded and total size (-1 if not @@ -57,7 +72,7 @@ export class RequestInfo { * Note: The XhrIo passed to this function may be reused after this callback * returns. Do not keep a reference to it in any way. */ - public handler: RequestHandler, + public handler: RequestHandler, public timeout: number ) {} } diff --git a/packages/storage/src/implementation/requests.ts b/packages/storage/src/implementation/requests.ts index 8b62702156f..ab3b4e26827 100644 --- a/packages/storage/src/implementation/requests.ts +++ b/packages/storage/src/implementation/requests.ts @@ -44,7 +44,7 @@ import { fromResponseString } from './list'; import { RequestInfo, UrlParams } from './requestinfo'; import { isString } from './type'; import { makeUrl } from './url'; -import { Connection } from './connection'; +import { Connection, ConnectionType } from './connection'; import { FirebaseStorageImpl } from '../service'; /** @@ -59,8 +59,8 @@ export function handlerCheck(cndn: boolean): void { export function metadataHandler( service: FirebaseStorageImpl, mappings: Mappings -): (p1: Connection, p2: string) => Metadata { - function handler(xhr: Connection, text: string): Metadata { +): (p1: Connection, p2: string) => Metadata { + function handler(xhr: Connection, text: string): Metadata { const metadata = fromResourceString(service, text, mappings); handlerCheck(metadata !== null); return metadata as Metadata; @@ -71,8 +71,8 @@ export function metadataHandler( export function listHandler( service: FirebaseStorageImpl, bucket: string -): (p1: Connection, p2: string) => ListResult { - function handler(xhr: Connection, text: string): ListResult { +): (p1: Connection, p2: string) => ListResult { + function handler(xhr: Connection, text: string): ListResult { const listResult = fromResponseString(service, bucket, text); handlerCheck(listResult !== null); return listResult as ListResult; @@ -83,8 +83,8 @@ export function listHandler( export function downloadUrlHandler( service: FirebaseStorageImpl, mappings: Mappings -): (p1: Connection, p2: string) => string | null { - function handler(xhr: Connection, text: string): string | null { +): (p1: Connection, p2: string) => string | null { + function handler(xhr: Connection, text: string): string | null { const metadata = fromResourceString(service, text, mappings); handlerCheck(metadata !== null); return downloadUrlFromResourceString( @@ -99,14 +99,17 @@ export function downloadUrlHandler( export function sharedErrorHandler( location: Location -): (p1: Connection, p2: StorageError) => StorageError { - function errorHandler(xhr: Connection, err: StorageError): StorageError { +): (p1: Connection, p2: StorageError) => StorageError { + function errorHandler( + xhr: Connection, + err: StorageError + ): StorageError { let newErr; if (xhr.getStatus() === 401) { if ( // This exact message string is the only consistent part of the // server's error response that identifies it as an App Check error. - xhr.getResponseText().includes('Firebase App Check token is invalid') + xhr.getErrorText().includes('Firebase App Check token is invalid') ) { newErr = unauthorizedApp(); } else { @@ -131,10 +134,13 @@ export function sharedErrorHandler( export function objectErrorHandler( location: Location -): (p1: Connection, p2: StorageError) => StorageError { +): (p1: Connection, p2: StorageError) => StorageError { const shared = sharedErrorHandler(location); - function errorHandler(xhr: Connection, err: StorageError): StorageError { + function errorHandler( + xhr: Connection, + err: StorageError + ): StorageError { let newErr = shared(xhr, err); if (xhr.getStatus() === 404) { newErr = objectNotFound(location.path); @@ -149,7 +155,7 @@ export function getMetadata( service: FirebaseStorageImpl, location: Location, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'GET'; @@ -170,7 +176,7 @@ export function list( delimiter?: string, pageToken?: string | null, maxResults?: number | null -): RequestInfo { +): RequestInfo { const urlParams: UrlParams = {}; if (location.isRoot) { urlParams['prefix'] = ''; @@ -201,11 +207,34 @@ export function list( return requestInfo; } +export function getBytes( + service: FirebaseStorageImpl, + location: Location, + maxDownloadSizeBytes?: number +): RequestInfo { + const urlPart = location.fullServerUrl(); + const url = makeUrl(urlPart, service.host, service._protocol) + '?alt=media'; + const method = 'GET'; + const timeout = service.maxOperationRetryTime; + const requestInfo = new RequestInfo( + url, + method, + (_: Connection, data: I) => data, + timeout + ); + requestInfo.errorHandler = objectErrorHandler(location); + if (maxDownloadSizeBytes !== undefined) { + requestInfo.headers['Range'] = `bytes=0-${maxDownloadSizeBytes}`; + requestInfo.successCodes = [200 /* OK */, 206 /* Partial Content */]; + } + return requestInfo; +} + export function getDownloadUrl( service: FirebaseStorageImpl, location: Location, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'GET'; @@ -225,7 +254,7 @@ export function updateMetadata( location: Location, metadata: Partial, mappings: Mappings -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'PATCH'; @@ -247,13 +276,13 @@ export function updateMetadata( export function deleteObject( service: FirebaseStorageImpl, location: Location -): RequestInfo { +): RequestInfo { const urlPart = location.fullServerUrl(); const url = makeUrl(urlPart, service.host, service._protocol); const method = 'DELETE'; const timeout = service.maxOperationRetryTime; - function handler(_xhr: Connection, _text: string): void {} + function handler(_xhr: Connection, _text: string): void {} const requestInfo = new RequestInfo(url, method, handler, timeout); requestInfo.successCodes = [200, 204]; requestInfo.errorHandler = objectErrorHandler(location); @@ -294,7 +323,7 @@ export function multipartUpload( mappings: Mappings, blob: FbsBlob, metadata?: Metadata | null -): RequestInfo { +): RequestInfo { const urlPart = location.bucketOnlyServerUrl(); const headers: { [prop: string]: string } = { 'X-Goog-Upload-Protocol': 'multipart' @@ -368,7 +397,7 @@ export class ResumableUploadStatus { } export function checkResumeHeader_( - xhr: Connection, + xhr: Connection, allowed?: string[] ): string { let status: string | null = null; @@ -388,7 +417,7 @@ export function createResumableUpload( mappings: Mappings, blob: FbsBlob, metadata?: Metadata | null -): RequestInfo { +): RequestInfo { const urlPart = location.bucketOnlyServerUrl(); const metadataForUpload = metadataForUpload_(location, blob, metadata); const urlParams: UrlParams = { name: metadataForUpload['fullPath']! }; @@ -404,7 +433,7 @@ export function createResumableUpload( const body = toResourceString(metadataForUpload, mappings); const timeout = service.maxUploadRetryTime; - function handler(xhr: Connection): string { + function handler(xhr: Connection): string { checkResumeHeader_(xhr); let url; try { @@ -431,10 +460,10 @@ export function getResumableUploadStatus( location: Location, url: string, blob: FbsBlob -): RequestInfo { +): RequestInfo { const headers = { 'X-Goog-Upload-Command': 'query' }; - function handler(xhr: Connection): ResumableUploadStatus { + function handler(xhr: Connection): ResumableUploadStatus { const status = checkResumeHeader_(xhr, ['active', 'final']); let sizeString: string | null = null; try { @@ -484,7 +513,7 @@ export function continueResumableUpload( mappings: Mappings, status?: ResumableUploadStatus | null, progressCallback?: ((p1: number, p2: number) => void) | null -): RequestInfo { +): RequestInfo { // TODO(andysoto): standardize on internal asserts // assert(!(opt_status && opt_status.finalized)); const status_ = new ResumableUploadStatus(0, 0); @@ -516,7 +545,10 @@ export function continueResumableUpload( throw cannotSliceBlob(); } - function handler(xhr: Connection, text: string): ResumableUploadStatus { + function handler( + xhr: Connection, + text: string + ): ResumableUploadStatus { // TODO(andysoto): Verify the MD5 of each uploaded range: // the 'x-range-md5' header comes back with status code 308 responses. // We'll only be able to bail out though, because you can't re-upload a diff --git a/packages/storage/src/platform/browser/connection.ts b/packages/storage/src/platform/browser/connection.ts index 087f1d31544..442962dafe8 100644 --- a/packages/storage/src/platform/browser/connection.ts +++ b/packages/storage/src/platform/browser/connection.ts @@ -16,27 +16,31 @@ */ import { - Headers, Connection, - ErrorCode + ConnectionType, + ErrorCode, + Headers } from '../../implementation/connection'; import { internalError } from '../../implementation/error'; /** An override for the text-based Connection. Used in tests. */ -let connectionFactoryOverride: (() => Connection) | null = null; +let textFactoryOverride: (() => Connection) | null = null; /** * Network layer for browsers. We use this instead of goog.net.XhrIo because * goog.net.XhrIo is hyuuuuge and doesn't work in React Native on Android. */ -export class XhrConnection implements Connection { - private xhr_: XMLHttpRequest; +abstract class XhrConnection + implements Connection +{ + protected xhr_: XMLHttpRequest; private errorCode_: ErrorCode; private sendPromise_: Promise; - private sent_: boolean = false; + protected sent_: boolean = false; constructor() { this.xhr_ = new XMLHttpRequest(); + this.initXhr(); this.errorCode_ = ErrorCode.NO_ERROR; this.sendPromise_ = new Promise(resolve => { this.xhr_.addEventListener('abort', () => { @@ -53,6 +57,8 @@ export class XhrConnection implements Connection { }); } + abstract initXhr(): void; + send( url: string, method: string, @@ -97,11 +103,18 @@ export class XhrConnection implements Connection { } } - getResponseText(): string { + getResponse(): T { + if (!this.sent_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.xhr_.response; + } + + getErrorText(): string { if (!this.sent_) { - throw internalError('cannot .getResponseText() before sending'); + throw internalError('cannot .getErrorText() before sending'); } - return this.xhr_.responseText; + return this.xhr_.statusText; } /** Aborts the request. */ @@ -126,12 +139,44 @@ export class XhrConnection implements Connection { } } -export function newConnection(): Connection { - return connectionFactoryOverride - ? connectionFactoryOverride() - : new XhrConnection(); +export class XhrTextConnection extends XhrConnection { + initXhr(): void { + this.xhr_.responseType = 'text'; + } +} + +export function newTextConnection(): Connection { + return textFactoryOverride ? textFactoryOverride() : new XhrTextConnection(); +} + +export class XhrBytesConnection extends XhrConnection { + private data_?: ArrayBuffer; + + initXhr(): void { + this.xhr_.responseType = 'arraybuffer'; + } +} + +export function newBytesConnection(): Connection { + return new XhrBytesConnection(); +} + +export class XhrBlobConnection extends XhrConnection { + initXhr(): void { + this.xhr_.responseType = 'blob'; + } +} + +export function newBlobConnection(): Connection { + return new XhrBlobConnection(); +} + +export function newStreamConnection(): Connection { + throw new Error('Streams are only supported on Node'); } -export function injectTestConnection(factory: (() => Connection) | null): void { - connectionFactoryOverride = factory; +export function injectTestConnection( + factory: (() => Connection) | null +): void { + textFactoryOverride = factory; } diff --git a/packages/storage/src/platform/connection.ts b/packages/storage/src/platform/connection.ts index 93863e3a70b..a647c19cacf 100644 --- a/packages/storage/src/platform/connection.ts +++ b/packages/storage/src/platform/connection.ts @@ -16,16 +16,36 @@ */ import { Connection } from '../implementation/connection'; import { - newConnection as nodeNewConnection, + newTextConnection as nodeNewTextConnection, + newBytesConnection as nodeNewBytesConnection, + newBlobConnection as nodeNewBlobConnection, + newStreamConnection as nodeNewStreamConnection, injectTestConnection as nodeInjectTestConnection } from './node/connection'; -export function newConnection(): Connection { +export function injectTestConnection( + factory: (() => Connection) | null +): void { // This file is only used under ts-node. - return nodeNewConnection(); + nodeInjectTestConnection(factory); } -export function injectTestConnection(factory: (() => Connection) | null): void { +export function newTextConnection(): Connection { // This file is only used under ts-node. - nodeInjectTestConnection(factory); + return nodeNewTextConnection(); +} + +export function newBytesConnection(): Connection { + // This file is only used in Node.js tests using ts-node. + return nodeNewBytesConnection(); +} + +export function newBlobConnection(): Connection { + // This file is only used in Node.js tests using ts-node. + return nodeNewBlobConnection(); +} + +export function newStreamConnection(): Connection { + // This file is only used in Node.js tests using ts-node. + return nodeNewStreamConnection(); } diff --git a/packages/storage/src/platform/node/connection.ts b/packages/storage/src/platform/node/connection.ts index 5bbadc98141..a656a9f6f84 100644 --- a/packages/storage/src/platform/node/connection.ts +++ b/packages/storage/src/platform/node/connection.ts @@ -15,12 +15,16 @@ * limitations under the License. */ -import { ErrorCode, Connection } from '../../implementation/connection'; +import { + Connection, + ConnectionType, + ErrorCode +} from '../../implementation/connection'; import { internalError } from '../../implementation/error'; -import nodeFetch, { FetchError } from 'node-fetch'; +import nodeFetch, { Headers } from 'node-fetch'; /** An override for the text-based Connection. Used in tests. */ -let connectionFactoryOverride: (() => Connection) | null = null; +let textFactoryOverride: (() => Connection) | null = null; /** * Network layer that works in Node. @@ -28,20 +32,22 @@ let connectionFactoryOverride: (() => Connection) | null = null; * This network implementation should not be used in browsers as it does not * support progress updates. */ -export class FetchConnection implements Connection { - private errorCode_: ErrorCode; - private statusCode_: number | undefined; - private body_: string | undefined; - private headers_: Headers | undefined; - private sent_: boolean = false; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private fetch_: typeof window.fetch = nodeFetch as any; +abstract class FetchConnection + implements Connection +{ + protected errorCode_: ErrorCode; + protected statusCode_: number | undefined; + protected body_: ArrayBuffer | undefined; + protected errorText_ = ''; + protected headers_: Headers | undefined; + protected sent_: boolean = false; + protected fetch_ = nodeFetch; constructor() { this.errorCode_ = ErrorCode.NO_ERROR; } - send( + async send( url: string, method: string, body?: ArrayBufferView | Blob | string, @@ -52,24 +58,22 @@ export class FetchConnection implements Connection { } this.sent_ = true; - return this.fetch_(url, { - method, - headers: headers || {}, - body - }).then( - resp => { - this.headers_ = resp.headers; - this.statusCode_ = resp.status; - return resp.text().then(body => { - this.body_ = body; - }); - }, - (_err: FetchError) => { - this.errorCode_ = ErrorCode.NETWORK_ERROR; - // emulate XHR which sets status to 0 when encountering a network error - this.statusCode_ = 0; - } - ); + try { + const response = await this.fetch_(url, { + method, + headers: headers || {}, + body: body as ArrayBufferView | string + }); + this.headers_ = response.headers; + this.statusCode_ = response.status; + this.errorCode_ = ErrorCode.NO_ERROR; + this.body_ = await response.arrayBuffer(); + } catch (e) { + this.errorText_ = e.message; + // emulate XHR which sets status to 0 when encountering a network error + this.statusCode_ = 0; + this.errorCode_ = ErrorCode.NETWORK_ERROR; + } } getErrorCode(): ErrorCode { @@ -86,13 +90,10 @@ export class FetchConnection implements Connection { return this.statusCode_; } - getResponseText(): string { - if (this.body_ === undefined) { - throw internalError( - 'cannot .getResponseText() before receiving response' - ); - } - return this.body_; + abstract getResponse(): T; + + getErrorText(): string { + return this.errorText_; } abort(): void { @@ -102,7 +103,7 @@ export class FetchConnection implements Connection { getResponseHeader(header: string): string | null { if (!this.headers_) { throw internalError( - 'cannot .getResponseText() before receiving response' + 'cannot .getResponseHeader() before receiving response' ); } return this.headers_.get(header); @@ -112,20 +113,89 @@ export class FetchConnection implements Connection { // Not supported } - /** - * @override - */ removeUploadProgressListener(listener: (p1: ProgressEvent) => void): void { // Not supported } } -export function newConnection(): Connection { - return connectionFactoryOverride - ? connectionFactoryOverride() - : new FetchConnection(); +export class FetchTextConnection extends FetchConnection { + getResponse(): string { + if (!this.body_) { + throw internalError('cannot .getResponse() before receiving response'); + } + return Buffer.from(this.body_).toString('utf-8'); + } +} + +export function newTextConnection(): Connection { + return textFactoryOverride + ? textFactoryOverride() + : new FetchTextConnection(); +} + +export class FetchBytesConnection extends FetchConnection { + getResponse(): ArrayBuffer { + if (!this.body_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.body_; + } +} + +export function newBytesConnection(): Connection { + return new FetchBytesConnection(); +} + +export class FetchStreamConnection extends FetchConnection { + private stream_: NodeJS.ReadableStream | null = null; + + async send( + url: string, + method: string, + body?: ArrayBufferView | Blob | string, + headers?: Record + ): Promise { + if (this.sent_) { + throw internalError('cannot .send() more than once'); + } + this.sent_ = true; + + try { + const response = await this.fetch_(url, { + method, + headers: headers || {}, + body: body as ArrayBufferView | string + }); + this.headers_ = response.headers; + this.statusCode_ = response.status; + this.errorCode_ = ErrorCode.NO_ERROR; + this.stream_ = response.body; + } catch (e) { + this.errorText_ = e.message; + // emulate XHR which sets status to 0 when encountering a network error + this.statusCode_ = 0; + this.errorCode_ = ErrorCode.NETWORK_ERROR; + } + } + + getResponse(): NodeJS.ReadableStream { + if (!this.stream_) { + throw internalError('cannot .getResponse() before sending'); + } + return this.stream_; + } +} + +export function newStreamConnection(): Connection { + return new FetchStreamConnection(); +} + +export function newBlobConnection(): Connection { + throw new Error('Blobs are not supported on Node'); } -export function injectTestConnection(factory: (() => Connection) | null): void { - connectionFactoryOverride = factory; +export function injectTestConnection( + factory: (() => Connection) | null +): void { + textFactoryOverride = factory; } diff --git a/packages/storage/src/reference.ts b/packages/storage/src/reference.ts index a89fd5a66b1..1657ed2383e 100644 --- a/packages/storage/src/reference.ts +++ b/packages/storage/src/reference.ts @@ -19,27 +19,35 @@ * @fileoverview Defines the Firebase StorageReference class. */ +import { PassThrough, Transform, TransformOptions } from 'stream'; + import { FbsBlob } from './implementation/blob'; import { Location } from './implementation/location'; import { getMappings } from './implementation/metadata'; -import { child, parent, lastComponent } from './implementation/path'; +import { child, lastComponent, parent } from './implementation/path'; import { - list as requestsList, - getMetadata as requestsGetMetadata, - updateMetadata as requestsUpdateMetadata, - getDownloadUrl as requestsGetDownloadUrl, deleteObject as requestsDeleteObject, - multipartUpload + getBytes, + getDownloadUrl as requestsGetDownloadUrl, + getMetadata as requestsGetMetadata, + list as requestsList, + multipartUpload, + updateMetadata as requestsUpdateMetadata } from './implementation/requests'; import { ListOptions, UploadResult } from './public-types'; -import { StringFormat, dataFromString } from './implementation/string'; +import { dataFromString, StringFormat } from './implementation/string'; import { Metadata } from './metadata'; import { FirebaseStorageImpl } from './service'; import { ListResult } from './list'; import { UploadTask } from './task'; import { invalidRootOperation, noDownloadURL } from './implementation/error'; import { validateNumber } from './implementation/type'; -import { newConnection } from './platform/connection'; +import { + newBlobConnection, + newBytesConnection, + newStreamConnection, + newTextConnection +} from './platform/connection'; /** * Provides methods to interact with a bucket in the Firebase Storage service. @@ -143,6 +151,96 @@ export class Reference { } } +/** + * Download the bytes at the object's location. + * @returns A Promise containing the downloaded bytes. + */ +export function getBytesInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): Promise { + ref._throwIfRoot('getBytes'); + const requestInfo = getBytes( + ref.storage, + ref._location, + maxDownloadSizeBytes + ); + return ref.storage + .makeRequestWithTokens(requestInfo, newBytesConnection) + .then(bytes => + maxDownloadSizeBytes !== undefined + ? // GCS may not honor the Range header for small files + bytes.slice(0, maxDownloadSizeBytes) + : bytes + ); +} + +/** + * Download the bytes at the object's location. + * @returns A Promise containing the downloaded blob. + */ +export function getBlobInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): Promise { + ref._throwIfRoot('getBlob'); + const requestInfo = getBytes( + ref.storage, + ref._location, + maxDownloadSizeBytes + ); + return ref.storage + .makeRequestWithTokens(requestInfo, newBlobConnection) + .then(blob => + maxDownloadSizeBytes !== undefined + ? // GCS may not honor the Range header for small files + blob.slice(0, maxDownloadSizeBytes) + : blob + ); +} + +/** Stream the bytes at the object's location. */ +export function getStreamInternal( + ref: Reference, + maxDownloadSizeBytes?: number +): NodeJS.ReadableStream { + ref._throwIfRoot('getStream'); + const requestInfo = getBytes( + ref.storage, + ref._location, + maxDownloadSizeBytes + ); + + /** A transformer that passes through the first n bytes. */ + const newMaxSizeTransform: (n: number) => TransformOptions = n => { + let missingBytes = n; + return { + transform(chunk, encoding, callback) { + // GCS may not honor the Range header for small files + if (chunk.length < missingBytes) { + this.push(chunk); + missingBytes -= chunk.length; + } else { + this.push(chunk.slice(0, missingBytes)); + this.emit('end'); + } + callback(); + } + } as TransformOptions; + }; + + const result = + maxDownloadSizeBytes !== undefined + ? new Transform(newMaxSizeTransform(maxDownloadSizeBytes)) + : new PassThrough(); + + ref.storage + .makeRequestWithTokens(requestInfo, newStreamConnection) + .then(stream => stream.pipe(result)) + .catch(e => result.destroy(e)); + return result; +} + /** * Uploads data to this object's location. * The upload is not resumable. @@ -166,7 +264,7 @@ export function uploadBytes( metadata ); return ref.storage - .makeRequestWithTokens(requestInfo, newConnection) + .makeRequestWithTokens(requestInfo, newTextConnection) .then(finalMetadata => { return { metadata: finalMetadata, @@ -312,7 +410,7 @@ export function list( op.pageToken, op.maxResults ); - return ref.storage.makeRequestWithTokens(requestInfo, newConnection); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -329,7 +427,7 @@ export function getMetadata(ref: Reference): Promise { ref._location, getMappings() ); - return ref.storage.makeRequestWithTokens(requestInfo, newConnection); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -354,7 +452,7 @@ export function updateMetadata( metadata, getMappings() ); - return ref.storage.makeRequestWithTokens(requestInfo, newConnection); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** @@ -371,7 +469,7 @@ export function getDownloadURL(ref: Reference): Promise { getMappings() ); return ref.storage - .makeRequestWithTokens(requestInfo, newConnection) + .makeRequestWithTokens(requestInfo, newTextConnection) .then(url => { if (url === null) { throw noDownloadURL(); @@ -389,7 +487,7 @@ export function getDownloadURL(ref: Reference): Promise { export function deleteObject(ref: Reference): Promise { ref._throwIfRoot('deleteObject'); const requestInfo = requestsDeleteObject(ref.storage, ref._location); - return ref.storage.makeRequestWithTokens(requestInfo, newConnection); + return ref.storage.makeRequestWithTokens(requestInfo, newTextConnection); } /** diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index c1e2bd080d4..3034543e531 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -39,7 +39,7 @@ import { import { validateNumber } from './implementation/type'; import { FirebaseStorage } from './public-types'; import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; -import { Connection } from './implementation/connection'; +import { Connection, ConnectionType } from './implementation/connection'; export function isUrl(path?: string): boolean { return /^[A-Za-z]+:\/\//.test(path as string); @@ -298,12 +298,12 @@ export class FirebaseStorageImpl implements FirebaseStorage { * @param requestInfo - HTTP RequestInfo object * @param authToken - Firebase auth token */ - _makeRequest( - requestInfo: RequestInfo, - requestFactory: () => Connection, + _makeRequest( + requestInfo: RequestInfo, + requestFactory: () => Connection, authToken: string | null, appCheckToken: string | null - ): Request { + ): Request { if (!this._deleted) { const request = makeRequest( requestInfo, @@ -325,10 +325,10 @@ export class FirebaseStorageImpl implements FirebaseStorage { } } - async makeRequestWithTokens( - requestInfo: RequestInfo, - requestFactory: () => Connection - ): Promise { + async makeRequestWithTokens( + requestInfo: RequestInfo, + requestFactory: () => Connection + ): Promise { const [authToken, appCheckToken] = await Promise.all([ this._getAuthToken(), this._getAppCheckToken() diff --git a/packages/storage/src/task.ts b/packages/storage/src/task.ts index 3cf0fb9f0e8..62689856153 100644 --- a/packages/storage/src/task.ts +++ b/packages/storage/src/task.ts @@ -52,7 +52,7 @@ import { multipartUpload } from './implementation/requests'; import { Reference } from './reference'; -import { newConnection } from './platform/connection'; +import { newTextConnection } from './platform/connection'; /** * Represents a blob being uploaded. Can be used to pause/resume/cancel the @@ -208,7 +208,7 @@ export class UploadTask { ); const createRequest = this._ref.storage._makeRequest( requestInfo, - newConnection, + newTextConnection, authToken, appCheckToken ); @@ -234,7 +234,7 @@ export class UploadTask { ); const statusRequest = this._ref.storage._makeRequest( requestInfo, - newConnection, + newTextConnection, authToken, appCheckToken ); @@ -281,7 +281,7 @@ export class UploadTask { } const uploadRequest = this._ref.storage._makeRequest( requestInfo, - newConnection, + newTextConnection, authToken, appCheckToken ); @@ -318,7 +318,7 @@ export class UploadTask { ); const metadataRequest = this._ref.storage._makeRequest( requestInfo, - newConnection, + newTextConnection, authToken, appCheckToken ); @@ -342,7 +342,7 @@ export class UploadTask { ); const multipartRequest = this._ref.storage._makeRequest( requestInfo, - newConnection, + newTextConnection, authToken, appCheckToken ); diff --git a/packages/storage/test/browser/blob.test.ts b/packages/storage/test/browser/blob.test.ts index eafdfacc184..b6c56eafa66 100644 --- a/packages/storage/test/browser/blob.test.ts +++ b/packages/storage/test/browser/blob.test.ts @@ -15,13 +15,31 @@ * limitations under the License. */ -import { assert } from 'chai'; +import { assert, expect } from 'chai'; import * as sinon from 'sinon'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { FirebaseApp, deleteApp } from '@firebase/app'; + import { FbsBlob } from '../../src/implementation/blob'; import * as type from '../../src/implementation/type'; import * as testShared from '../unit/testshared'; +import { createApp, createStorage } from '../integration/integration.test'; +import { getBlob, ref, uploadBytes } from '../../src'; +import * as types from '../../src/public-types'; describe('Firebase Storage > Blob', () => { + let app: FirebaseApp; + let storage: types.FirebaseStorage; + + beforeEach(async () => { + app = await createApp(); + storage = createStorage(app); + }); + + afterEach(async () => { + await deleteApp(app); + }); + let stubs: sinon.SinonStub[] = []; before(() => { const definedStub = sinon.stub(type, 'isNativeBlobDefined'); @@ -47,6 +65,7 @@ describe('Firebase Storage > Blob', () => { new Uint8Array([2, 3, 4, 5]) ); }); + it('Blobs are merged with strings correctly', () => { const blob = new FbsBlob(new Uint8Array([1, 2, 3, 4])); const merged = FbsBlob.getBlob('what', blob, '\ud83d\ude0a ')!; @@ -70,4 +89,34 @@ describe('Firebase Storage > Blob', () => { assert.equal(20, concatenated!.size()); }); + + it('can get blob', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const blob = await getBlob(reference); + const bytes = await blob.arrayBuffer(); + expect(new Uint8Array(bytes)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get the first n-bytes of a blob', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 5])); + const blob = await getBlob(reference, 2); + const bytes = await blob.arrayBuffer(); + expect(new Uint8Array(bytes)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getBlob() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + try { + await getBlob(reference); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); }); diff --git a/packages/storage/test/browser/connection.test.ts b/packages/storage/test/browser/connection.test.ts index d89397696f3..b869c9ee31b 100644 --- a/packages/storage/test/browser/connection.test.ts +++ b/packages/storage/test/browser/connection.test.ts @@ -18,12 +18,12 @@ import { expect } from 'chai'; import { SinonFakeXMLHttpRequest, useFakeXMLHttpRequest } from 'sinon'; import { ErrorCode } from '../../src/implementation/connection'; -import { XhrConnection } from '../../src/platform/browser/connection'; +import { XhrBytesConnection } from '../../src/platform/browser/connection'; describe('Connections', () => { it('XhrConnection.send() should not reject on network errors', async () => { const fakeXHR = useFakeXMLHttpRequest(); - const connection = new XhrConnection(); + const connection = new XhrBytesConnection(); const sendPromise = connection.send('testurl', 'GET'); // simulate a network error ((connection as any).xhr_ as SinonFakeXMLHttpRequest).error(); diff --git a/packages/storage/test/integration/integration.test.ts b/packages/storage/test/integration/integration.test.ts index 312eb3dfe5b..80082470dac 100644 --- a/packages/storage/test/integration/integration.test.ts +++ b/packages/storage/test/integration/integration.test.ts @@ -29,8 +29,9 @@ import { deleteObject, getMetadata, updateMetadata, - listAll -} from '../../src/index'; + listAll, + getBytes +} from '../../src'; import { use, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -46,19 +47,28 @@ export const STORAGE_BUCKET = PROJECT_CONFIG.storageBucket; export const API_KEY = PROJECT_CONFIG.apiKey; export const AUTH_DOMAIN = PROJECT_CONFIG.authDomain; -describe('FirebaseStorage Integration tests', () => { +export async function createApp(): Promise { + const app = initializeApp({ + apiKey: API_KEY, + projectId: PROJECT_ID, + storageBucket: STORAGE_BUCKET, + authDomain: AUTH_DOMAIN + }); + await signInAnonymously(getAuth(app)); + return app; +} + +export function createStorage(app: FirebaseApp): types.FirebaseStorage { + return getStorage(app); +} + +describe('FirebaseStorage Exp', () => { let app: FirebaseApp; let storage: types.FirebaseStorage; beforeEach(async () => { - app = initializeApp({ - apiKey: API_KEY, - projectId: PROJECT_ID, - storageBucket: STORAGE_BUCKET, - authDomain: AUTH_DOMAIN - }); - await signInAnonymously(getAuth(app)); - storage = getStorage(app); + app = await createApp(); + storage = createStorage(app); }); afterEach(async () => { @@ -71,6 +81,34 @@ describe('FirebaseStorage Integration tests', () => { expect(snap.metadata.timeCreated).to.exist; }); + it('can get bytes', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const bytes = await getBytes(reference); + expect(new Uint8Array(bytes)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get first n bytes', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3])); + const bytes = await getBytes(reference, 2); + expect(new Uint8Array(bytes)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getBytes() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + try { + await getBytes(reference); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); + it('can upload bytes (resumable)', async () => { const reference = ref(storage, 'public/exp-bytesresumable'); const snap = await uploadBytesResumable( diff --git a/packages/storage/test/unit/connection.test.ts b/packages/storage/test/node/connection.test.ts similarity index 89% rename from packages/storage/test/unit/connection.test.ts rename to packages/storage/test/node/connection.test.ts index d9190da7330..32c499f0209 100644 --- a/packages/storage/test/unit/connection.test.ts +++ b/packages/storage/test/node/connection.test.ts @@ -18,11 +18,11 @@ import { stub } from 'sinon'; import { expect } from 'chai'; import { ErrorCode } from '../../src/implementation/connection'; -import { FetchConnection } from '../../src/platform/node/connection'; +import { FetchBytesConnection } from '../../src/platform/node/connection'; describe('Connections', () => { it('FetchConnection.send() should not reject on network errors', async () => { - const connection = new FetchConnection(); + const connection = new FetchBytesConnection(); // need the casting here because fetch_ is a private member stub(connection as any, 'fetch_').rejects(); diff --git a/packages/storage/test/node/stream.test.ts b/packages/storage/test/node/stream.test.ts new file mode 100644 index 00000000000..bbde160692a --- /dev/null +++ b/packages/storage/test/node/stream.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { createApp, createStorage } from '../integration/integration.test'; +import { FirebaseApp, deleteApp } from '@firebase/app'; +import { getStream, ref, uploadBytes } from '../../src/index.node'; +import * as types from '../../src/public-types'; + +async function readData(reader: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const data: number[] = []; + reader.on('error', e => reject(e)); + reader.on('data', chunk => data.push(...Array.from(chunk as Buffer))); + reader.on('end', () => resolve(data)); + }); +} + +describe('Firebase Storage > getStream', () => { + let app: FirebaseApp; + let storage: types.FirebaseStorage; + + beforeEach(async () => { + app = await createApp(); + storage = createStorage(app); + }); + + afterEach(async () => { + await deleteApp(app); + }); + + it('can get stream', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3, 128, 255])); + const stream = await getStream(reference); + const data = await readData(stream); + expect(new Uint8Array(data)).to.deep.equal( + new Uint8Array([0, 1, 3, 128, 255]) + ); + }); + + it('can get first n bytes of stream', async () => { + const reference = ref(storage, 'public/exp-bytes'); + await uploadBytes(reference, new Uint8Array([0, 1, 3])); + const stream = await getStream(reference, 2); + const data = await readData(stream); + expect(new Uint8Array(data)).to.deep.equal(new Uint8Array([0, 1])); + }); + + it('getStream() throws for missing file', async () => { + const reference = ref(storage, 'public/exp-bytes-missing'); + const stream = getStream(reference); + try { + await readData(stream); + expect.fail(); + } catch (e) { + expect(e.message).to.satisfy((v: string) => + v.match(/Object 'public\/exp-bytes-missing' does not exist/) + ); + } + }); +}); diff --git a/packages/storage/test/unit/connection.ts b/packages/storage/test/unit/connection.ts index 11c27d29a6a..018f65f3671 100644 --- a/packages/storage/test/unit/connection.ts +++ b/packages/storage/test/unit/connection.ts @@ -35,7 +35,7 @@ export enum State { DONE = 2 } -export class TestingConnection implements Connection { +export class TestingConnection implements Connection { private state: State; private sendPromise: Promise; private resolve!: () => void; @@ -107,7 +107,11 @@ export class TestingConnection implements Connection { return this.status; } - getResponseText(): string { + getResponse(): string { + return this.responseText; + } + + getErrorText(): string { return this.responseText; } @@ -135,6 +139,8 @@ export class TestingConnection implements Connection { } } -export function newTestConnection(sendHook?: SendHook | null): Connection { +export function newTestConnection( + sendHook?: SendHook | null +): Connection { return new TestingConnection(sendHook ?? null); } diff --git a/packages/storage/test/unit/request.test.ts b/packages/storage/test/unit/request.test.ts index 633e828cc49..69b3dc41e1d 100644 --- a/packages/storage/test/unit/request.test.ts +++ b/packages/storage/test/unit/request.test.ts @@ -44,7 +44,7 @@ describe('Firebase Storage > Request', () => { } const spiedSend = sinon.spy(newSend); - function handler(connection: Connection, text: string): string { + function handler(connection: Connection, text: string): string { assert.equal(text, response); assert.equal(connection.getResponseHeader(responseHeader), responseValue); assert.equal(connection.getStatus(), status); @@ -92,7 +92,7 @@ describe('Firebase Storage > Request', () => { } const spiedSend = sinon.spy(newSend); - function handler(connection: Connection, text: string): string { + function handler(connection: Connection, text: string): string { return text; } diff --git a/packages/storage/test/unit/requests.test.ts b/packages/storage/test/unit/requests.test.ts index 25a02ce0337..8a7cff0ca4a 100644 --- a/packages/storage/test/unit/requests.test.ts +++ b/packages/storage/test/unit/requests.test.ts @@ -32,7 +32,8 @@ import { getResumableUploadStatus, ResumableUploadStatus, continueResumableUpload, - RESUMABLE_UPLOAD_CHUNK_SIZE + RESUMABLE_UPLOAD_CHUNK_SIZE, + getBytes } from '../../src/implementation/requests'; import { makeUrl } from '../../src/implementation/url'; import { unknown, StorageErrorCode } from '../../src/implementation/error'; @@ -178,12 +179,14 @@ describe('Firebase Storage > Requests', () => { } } - function checkMetadataHandler(requestInfo: RequestInfo): void { + function checkMetadataHandler( + requestInfo: RequestInfo + ): void { const metadata = requestInfo.handler(fakeXhrIo({}), serverResourceString); assert.deepEqual(metadata, metadataFromServerResource); } - function checkNoOpHandler(requestInfo: RequestInfo): void { + function checkNoOpHandler(requestInfo: RequestInfo): void { try { requestInfo.handler(fakeXhrIo({}), ''); } catch (e) { @@ -344,6 +347,14 @@ describe('Firebase Storage > Requests', () => { const url = requestInfo.handler(fakeXhrIo({}), serverResourceString); assert.equal(url, downloadUrlFromServerResource); }); + it('getBytes handler', () => { + const requestInfo = getBytes(storageService, locationNormal); + const bytes = requestInfo.handler( + fakeXhrIo({}), + new Uint8Array([1, 128, 255]) + ); + assert.deepEqual(new Uint8Array(bytes), new Uint8Array([1, 128, 255])); + }); it('updateMetadata requestinfo', () => { const maps = [ [locationNormal, locationNormalUrl], diff --git a/packages/storage/test/unit/testshared.ts b/packages/storage/test/unit/testshared.ts index 604b3a069ec..1ff22a03b5b 100644 --- a/packages/storage/test/unit/testshared.ts +++ b/packages/storage/test/unit/testshared.ts @@ -22,7 +22,11 @@ use(chaiAsPromised); import { FirebaseApp } from '@firebase/app-types'; import { CONFIG_STORAGE_BUCKET_KEY } from '../../src/implementation/constants'; import { StorageError } from '../../src/implementation/error'; -import { Headers, Connection } from '../../src/implementation/connection'; +import { + Headers, + Connection, + ConnectionType +} from '../../src/implementation/connection'; import { newTestConnection, TestingConnection } from './connection'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { @@ -110,7 +114,10 @@ export function makeFakeAppCheckProvider(tokenResult: { * Returns something that looks like an fbs.XhrIo with the given headers * and status. */ -export function fakeXhrIo(headers: Headers, status: number = 200): Connection { +export function fakeXhrIo( + headers: Headers, + status: number = 200 +): Connection { const lower: Headers = {}; for (const [key, value] of Object.entries(headers)) { lower[key.toLowerCase()] = value.toString(); @@ -130,7 +137,7 @@ export function fakeXhrIo(headers: Headers, status: number = 200): Connection { } }; - return fakeConnection as Connection; + return fakeConnection as Connection; } /**