diff --git a/.changeset/tricky-seahorses-look.md b/.changeset/tricky-seahorses-look.md new file mode 100644 index 00000000000..a1b31c63614 --- /dev/null +++ b/.changeset/tricky-seahorses-look.md @@ -0,0 +1,8 @@ +--- +'firebase': minor +'@firebase/storage': minor +'@firebase/storage-types': minor +--- + +Add `storage().useEmulator()` method to enable emulator mode for storage, allowing users +to set a storage emulator host and port. diff --git a/common/api-review/storage.api.md b/common/api-review/storage.api.md index 988b0fcc493..83939704281 100644 --- a/common/api-review/storage.api.md +++ b/common/api-review/storage.api.md @@ -61,7 +61,7 @@ export function getDownloadURL(ref: StorageReference): Promise; export function getMetadata(ref: StorageReference): Promise; // @public -export function getStorage(app: FirebaseApp, bucketUrl?: string): StorageService; +export function getStorage(app?: FirebaseApp, bucketUrl?: string): StorageService; // @public export function list(ref: StorageReference, options?: ListOptions): Promise; @@ -94,9 +94,9 @@ export class _Location { // (undocumented) get isRoot(): boolean; // (undocumented) - static makeFromBucketSpec(bucketString: string): _Location; + static makeFromBucketSpec(bucketString: string, host: string): _Location; // (undocumented) - static makeFromUrl(url: string): _Location; + static makeFromUrl(url: string, host: string): _Location; // (undocumented) get path(): string; } @@ -253,6 +253,9 @@ export interface UploadTaskSnapshot { totalBytes: number; } +// @public +export function useStorageEmulator(storage: StorageService, host: string, port: number): void; + // (No @packageDocumentation comment for this package) diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index d33bd26541b..b44df74f9b0 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -7612,6 +7612,13 @@ declare namespace firebase.storage { * @see {@link firebase.storage.Storage.maxUploadRetryTime} */ setMaxUploadRetryTime(time: number): any; + /** + * Modify this `Storage` instance to communicate with the Cloud Storage emulator. + * + * @param host - The emulator host (ex: localhost) + * @param port - The emulator port (ex: 5001) + */ + useEmulator(host: string, port: string): void; } /** diff --git a/packages/storage-types/index.d.ts b/packages/storage-types/index.d.ts index 5bd2b98dc05..735d8c32278 100644 --- a/packages/storage-types/index.d.ts +++ b/packages/storage-types/index.d.ts @@ -135,6 +135,7 @@ export class FirebaseStorage { refFromURL(url: string): Reference; setMaxOperationRetryTime(time: number): void; setMaxUploadRetryTime(time: number): void; + useEmulator(host: string, port: number): void; } declare module '@firebase/component' { diff --git a/packages/storage/compat/service.ts b/packages/storage/compat/service.ts index 3f4c0dd6870..b85aae6636f 100644 --- a/packages/storage/compat/service.ts +++ b/packages/storage/compat/service.ts @@ -18,9 +18,13 @@ import * as types from '@firebase/storage-types'; import { FirebaseApp } from '@firebase/app-types'; -import { StorageService, ref, _Location } from '../exp/api'; // import from the exp public API +import { ref, _Location } from '../exp/api'; // import from the exp public API import { ReferenceCompat } from './reference'; -import { isUrl } from '../src/service'; +import { + isUrl, + StorageService, + useStorageEmulator as internalUseEmulator +} from '../src/service'; import { invalidArgument } from '../src/implementation/error'; import { Compat } from '@firebase/util'; @@ -73,7 +77,7 @@ export class StorageServiceCompat ); } try { - _Location.makeFromUrl(url); + _Location.makeFromUrl(url, this._delegate.host); } catch (e) { throw invalidArgument( 'refFromUrl() expected a valid full URL but got an invalid one.' @@ -89,4 +93,8 @@ export class StorageServiceCompat setMaxOperationRetryTime(time: number): void { this._delegate.maxOperationRetryTime = time; } + + useEmulator(host: string, port: number): void { + internalUseEmulator(this._delegate, host, port); + } } diff --git a/packages/storage/exp/index.ts b/packages/storage/exp/index.ts index 670081acbf6..c105d1644b7 100644 --- a/packages/storage/exp/index.ts +++ b/packages/storage/exp/index.ts @@ -23,7 +23,10 @@ import { } from '@firebase/app-exp'; import { XhrIoPool } from '../src/implementation/xhriopool'; -import { StorageService as StorageServiceInternal } from '../src/service'; +import { + StorageService as StorageServiceInternal, + useStorageEmulator as useEmulatorInternal +} from '../src/service'; import { Component, ComponentType, @@ -36,6 +39,23 @@ import { name, version } from '../package.json'; import { StorageService } from './public-types'; import { STORAGE_TYPE } from './constants'; +/** + * Modify this `StorageService` instance to communicate with the Cloud Storage emulator. + * + * @param storage - The `StorageService` instance + * @param host - The emulator host (ex: localhost) + * @param port - The emulator port (ex: 5001) + * @public + */ +export function useStorageEmulator( + storage: StorageService, + host: string, + port: number +): void { + useEmulatorInternal(storage as StorageServiceInternal, host, port); +} + +export { StringFormat } from '../src/implementation/string'; export * from './api'; function factory( diff --git a/packages/storage/package.json b/packages/storage/package.json index 8a2ce2c9d22..a5502adf9d7 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -63,4 +63,4 @@ "url": "https://github.com/firebase/firebase-js-sdk/issues" }, "typings": "dist/index.d.ts" -} +} \ No newline at end of file diff --git a/packages/storage/src/implementation/location.ts b/packages/storage/src/implementation/location.ts index 649203b7a32..a12084337fa 100644 --- a/packages/storage/src/implementation/location.ts +++ b/packages/storage/src/implementation/location.ts @@ -53,10 +53,10 @@ export class Location { return '/b/' + encode(this.bucket) + '/o'; } - static makeFromBucketSpec(bucketString: string): Location { + static makeFromBucketSpec(bucketString: string, host: string): Location { let bucketLocation; try { - bucketLocation = Location.makeFromUrl(bucketString); + bucketLocation = Location.makeFromUrl(bucketString, host); } catch (e) { // Not valid URL, use as-is. This lets you put bare bucket names in // config. @@ -69,7 +69,7 @@ export class Location { } } - static makeFromUrl(url: string): Location { + static makeFromUrl(url: string, host: string): Location { let location: Location | null = null; const bucketDomain = '([A-Za-z0-9.\\-_]+)'; @@ -86,7 +86,7 @@ export class Location { loc.path_ = decodeURIComponent(loc.path); } const version = 'v[A-Za-z0-9_]+'; - const firebaseStorageHost = DEFAULT_HOST.replace(/[.]/g, '\\.'); + const firebaseStorageHost = host.replace(/[.]/g, '\\.'); const firebaseStoragePath = '(/([^?#]*).*)?$'; const firebaseStorageRegExp = new RegExp( `^https?://${firebaseStorageHost}/${version}/b/${bucketDomain}/o${firebaseStoragePath}`, @@ -95,7 +95,9 @@ export class Location { const firebaseStorageIndices = { bucket: 1, path: 3 }; const cloudStorageHost = - '(?:storage.googleapis.com|storage.cloud.google.com)'; + host === DEFAULT_HOST + ? '(?:storage.googleapis.com|storage.cloud.google.com)' + : host; const cloudStoragePath = '([^?#]*)'; const cloudStorageRegExp = new RegExp( `^https?://${cloudStorageHost}/${bucketDomain}/${cloudStoragePath}`, diff --git a/packages/storage/src/implementation/metadata.ts b/packages/storage/src/implementation/metadata.ts index a877fad7c5a..14b9f6aa530 100644 --- a/packages/storage/src/implementation/metadata.ts +++ b/packages/storage/src/implementation/metadata.ts @@ -155,7 +155,8 @@ export function fromResourceString( export function downloadUrlFromResourceString( metadata: Metadata, - resourceString: string + resourceString: string, + host: string ): string | null { const obj = jsonObjectOrNull(resourceString); if (obj === null) { @@ -176,7 +177,7 @@ export function downloadUrlFromResourceString( const bucket: string = metadata['bucket'] as string; const path: string = metadata['fullPath'] as string; const urlPart = '/b/' + encode(bucket) + '/o/' + encode(path); - const base = makeUrl(urlPart); + const base = makeUrl(urlPart, host); const queryString = makeQueryString({ alt: 'media', token diff --git a/packages/storage/src/implementation/requests.ts b/packages/storage/src/implementation/requests.ts index bb90e6150d0..860927fcb9e 100644 --- a/packages/storage/src/implementation/requests.ts +++ b/packages/storage/src/implementation/requests.ts @@ -86,7 +86,11 @@ export function downloadUrlHandler( function handler(xhr: XhrIo, text: string): string | null { const metadata = fromResourceString(service, text, mappings); handlerCheck(metadata !== null); - return downloadUrlFromResourceString(metadata as Metadata, text); + return downloadUrlFromResourceString( + metadata as Metadata, + text, + service.host + ); } return handler; } @@ -143,7 +147,7 @@ export function getMetadata( mappings: Mappings ): RequestInfo { const urlPart = location.fullServerUrl(); - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'GET'; const timeout = service.maxOperationRetryTime; const requestInfo = new RequestInfo( @@ -179,7 +183,7 @@ export function list( urlParams['maxResults'] = maxResults; } const urlPart = location.bucketOnlyServerUrl(); - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'GET'; const timeout = service.maxOperationRetryTime; const requestInfo = new RequestInfo( @@ -199,7 +203,7 @@ export function getDownloadUrl( mappings: Mappings ): RequestInfo { const urlPart = location.fullServerUrl(); - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'GET'; const timeout = service.maxOperationRetryTime; const requestInfo = new RequestInfo( @@ -219,7 +223,7 @@ export function updateMetadata( mappings: Mappings ): RequestInfo { const urlPart = location.fullServerUrl(); - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'PATCH'; const body = toResourceString(metadata, mappings); const headers = { 'Content-Type': 'application/json; charset=utf-8' }; @@ -241,7 +245,7 @@ export function deleteObject( location: Location ): RequestInfo { const urlPart = location.fullServerUrl(); - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'DELETE'; const timeout = service.maxOperationRetryTime; @@ -321,7 +325,7 @@ export function multipartUpload( throw cannotSliceBlob(); } const urlParams: UrlParams = { name: metadata_['fullPath']! }; - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'POST'; const timeout = service.maxUploadRetryTime; const requestInfo = new RequestInfo( @@ -381,7 +385,7 @@ export function createResumableUpload( const urlPart = location.bucketOnlyServerUrl(); const metadataForUpload = metadataForUpload_(location, blob, metadata); const urlParams: UrlParams = { name: metadataForUpload['fullPath']! }; - const url = makeUrl(urlPart); + const url = makeUrl(urlPart, service.host); const method = 'POST'; const headers = { 'X-Goog-Upload-Protocol': 'resumable', diff --git a/packages/storage/src/implementation/url.ts b/packages/storage/src/implementation/url.ts index caea91f35b3..d25f3aedc51 100644 --- a/packages/storage/src/implementation/url.ts +++ b/packages/storage/src/implementation/url.ts @@ -18,11 +18,16 @@ /** * @fileoverview Functions to create and manipulate URLs for the server API. */ -import { DEFAULT_HOST } from './constants'; import { UrlParams } from './requestinfo'; -export function makeUrl(urlPart: string): string { - return `https://${DEFAULT_HOST}/v0${urlPart}`; +export function makeUrl(urlPart: string, host: string): string { + const protocolMatch = host.match(/^(\w+):\/\/.+/); + const protocol = protocolMatch?.[1]; + let origin = host; + if (protocol == null) { + origin = `https://${host}`; + } + return `${origin}/v0${urlPart}`; } export function makeQueryString(params: UrlParams): string { diff --git a/packages/storage/src/reference.ts b/packages/storage/src/reference.ts index db08e704c42..7431146bba3 100644 --- a/packages/storage/src/reference.ts +++ b/packages/storage/src/reference.ts @@ -60,7 +60,7 @@ export class Reference { if (location instanceof Location) { this._location = location; } else { - this._location = Location.makeFromUrl(location); + this._location = Location.makeFromUrl(location, _service.host); } } diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 07a4dcdba54..b966a0a9a91 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -31,6 +31,7 @@ import { } from '@firebase/app-exp'; import { CONFIG_STORAGE_BUCKET_KEY, + DEFAULT_HOST, DEFAULT_MAX_OPERATION_RETRY_TIME, DEFAULT_MAX_UPLOAD_RETRY_TIME } from '../src/implementation/constants'; @@ -120,12 +121,23 @@ export function ref( } } -function extractBucket(config?: FirebaseOptions): Location | null { +function extractBucket( + host: string, + config?: FirebaseOptions +): Location | null { const bucketString = config?.[CONFIG_STORAGE_BUCKET_KEY]; if (bucketString == null) { return null; } - return Location.makeFromBucketSpec(bucketString); + return Location.makeFromBucketSpec(bucketString, host); +} + +export function useStorageEmulator( + storage: StorageService, + host: string, + port: number +): void { + storage.host = `http://${host}:${port}`; } /** @@ -134,7 +146,14 @@ function extractBucket(config?: FirebaseOptions): Location | null { * @param opt_url - gs:// url to a custom Storage Bucket */ export class StorageService implements _FirebaseService { - readonly _bucket: Location | null = null; + _bucket: Location | null = null; + /** + * This string can be in the formats: + * - host + * - host:port + * - protocol://host:port + */ + private _host: string = DEFAULT_HOST; protected readonly _appId: string | null = null; private readonly _requests: Set>; private _deleted: boolean = false; @@ -155,9 +174,27 @@ export class StorageService implements _FirebaseService { this._maxUploadRetryTime = DEFAULT_MAX_UPLOAD_RETRY_TIME; this._requests = new Set(); if (_url != null) { - this._bucket = Location.makeFromBucketSpec(_url); + this._bucket = Location.makeFromBucketSpec(_url, this._host); + } else { + this._bucket = extractBucket(this._host, this.app.options); + } + } + + get host(): string { + return this._host; + } + + /** + * Set host string for this service. + * @param host - host string in the form of host, host:port, + * or protocol://host:port + */ + set host(host: string) { + this._host = host; + if (this._url != null) { + this._bucket = Location.makeFromBucketSpec(this._url, host); } else { - this._bucket = extractBucket(this.app.options); + this._bucket = extractBucket(host, this.app.options); } } diff --git a/packages/storage/test/unit/requests.test.ts b/packages/storage/test/unit/requests.test.ts index e3bf6a0d7f3..f12d1479d9f 100644 --- a/packages/storage/test/unit/requests.test.ts +++ b/packages/storage/test/unit/requests.test.ts @@ -200,7 +200,7 @@ describe('Firebase Storage > Requests', () => { const requestInfo = getMetadata(storageService, location, mappings); assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'GET', body: null, headers: {}, @@ -220,7 +220,7 @@ describe('Firebase Storage > Requests', () => { const requestInfo = list(storageService, locationRoot, '/'); assertObjectIncludes( { - url: makeUrl(locationNormalNoObjUrl), + url: makeUrl(locationNormalNoObjUrl, storageService.host), method: 'GET', body: null, headers: {}, @@ -250,7 +250,7 @@ describe('Firebase Storage > Requests', () => { ); assertObjectIncludes( { - url: makeUrl(locationNoObjectUrl), + url: makeUrl(locationNoObjectUrl, storageService.host), method: 'GET', body: null, headers: {}, @@ -317,7 +317,7 @@ describe('Firebase Storage > Requests', () => { const requestInfo = getDownloadUrl(storageService, location, mappings); assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'GET', body: null, headers: {}, @@ -352,7 +352,7 @@ describe('Firebase Storage > Requests', () => { ); assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'PATCH', body: metadataString, headers: { 'Content-Type': metadataContentType }, @@ -383,7 +383,7 @@ describe('Firebase Storage > Requests', () => { const requestInfo = deleteObject(storageService, location); assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'DELETE', body: null, headers: {}, @@ -448,7 +448,7 @@ describe('Firebase Storage > Requests', () => { assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'POST', urlParams: { name: location.path }, headers: { @@ -493,7 +493,7 @@ describe('Firebase Storage > Requests', () => { ); assertObjectIncludes( { - url: makeUrl(url), + url: makeUrl(url, storageService.host), method: 'POST', urlParams: { name: location.path }, headers: { diff --git a/packages/storage/test/unit/service.compat.test.ts b/packages/storage/test/unit/service.compat.test.ts index 67886b9139a..9a79318cc2f 100644 --- a/packages/storage/test/unit/service.compat.test.ts +++ b/packages/storage/test/unit/service.compat.test.ts @@ -25,6 +25,8 @@ import { StorageService } from '../../src/service'; import { FirebaseApp } from '@firebase/app-types'; import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { TestingXhrIo } from './xhrio'; +import { Headers } from '../../src/implementation/xhrio'; const fakeAppGs = testShared.makeFakeApp('gs://mybucket'); const fakeAppGsEndingSlash = testShared.makeFakeApp('gs://mybucket/'); @@ -169,6 +171,31 @@ describe('Firebase Storage > Service', () => { }, 'storage/invalid-default-bucket'); }); }); + describe('useStorageEmulator(service, host, port)', () => { + it('sets emulator host correctly', done => { + function newSend( + xhrio: TestingXhrIo, + url: string, + method: string, + body?: ArrayBufferView | Blob | string | null, + headers?: Headers + ): void { + // Expect emulator host to be in url of storage operations requests, + // in this case getDownloadURL. + expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); + xhrio.abort(); + done(); + } + const service = makeService( + testShared.fakeApp, + testShared.fakeAuthProvider, + testShared.makePool(newSend) + ); + service.useEmulator('test.host.org', 1234); + expect(service._delegate.host).to.equal('http://test.host.org:1234'); + void service.ref('test.png').getDownloadURL(); + }); + }); describe('refFromURL', () => { const service = makeService( testShared.fakeApp, diff --git a/packages/storage/test/unit/service.exp.test.ts b/packages/storage/test/unit/service.exp.test.ts index eee6568e2ec..6f4d0a3d2f2 100644 --- a/packages/storage/test/unit/service.exp.test.ts +++ b/packages/storage/test/unit/service.exp.test.ts @@ -16,17 +16,20 @@ */ import { expect } from 'chai'; import { TaskEvent } from '../../src/implementation/taskenums'; +import { Headers } from '../../src/implementation/xhrio'; import { XhrIoPool } from '../../src/implementation/xhriopool'; -import { StorageService, ref } from '../../src/service'; +import { StorageService, ref, useStorageEmulator } from '../../src/service'; import * as testShared from './testshared'; import { DEFAULT_HOST } from '../../src/implementation/constants'; import { FirebaseStorageError } from '../../src/implementation/error'; import { Reference, getMetadata, - uploadBytesResumable + uploadBytesResumable, + getDownloadURL } from '../../src/reference'; import { Location } from '../../src/implementation/location'; +import { TestingXhrIo } from './xhrio'; const fakeAppGs = testShared.makeFakeApp('gs://mybucket'); const fakeAppGsEndingSlash = testShared.makeFakeApp('gs://mybucket/'); @@ -217,6 +220,31 @@ GOOG4-RSA-SHA256` ); }); }); + describe('useStorageEmulator(service, host, port)', () => { + it('sets emulator host correctly', done => { + function newSend( + xhrio: TestingXhrIo, + url: string, + method: string, + body?: ArrayBufferView | Blob | string | null, + headers?: Headers + ): void { + // Expect emulator host to be in url of storage operations requests, + // in this case getDownloadURL. + expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); + xhrio.abort(); + done(); + } + const service = new StorageService( + testShared.fakeApp, + testShared.fakeAuthProvider, + testShared.makePool(newSend) + ); + useStorageEmulator(service, 'test.host.org', 1234); + expect(service.host).to.equal('http://test.host.org:1234'); + void getDownloadURL(ref(service, 'test.png')); + }); + }); describe('ref(service, path)', () => { const service = new StorageService( testShared.fakeApp,