Skip to content

Add useEmulator() to Storage #4346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Apr 7, 2021
8 changes: 8 additions & 0 deletions .changeset/tricky-seahorses-look.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/firebase/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/storage-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down
13 changes: 11 additions & 2 deletions packages/storage/compat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
*/

import * as types from '@firebase/storage-types';
import { StorageService, isUrl, ref } from '../src/service';
import {
StorageService,
isUrl,
ref,
useStorageEmulator as internalUseEmulator
} from '../src/service';
import { Location } from '../src/implementation/location';
import { ReferenceCompat } from './reference';
import { invalidArgument } from '../src/implementation/error';
Expand Down Expand Up @@ -70,7 +75,7 @@ export class StorageServiceCompat implements types.FirebaseStorage {
);
}
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.'
Expand All @@ -86,4 +91,8 @@ export class StorageServiceCompat implements types.FirebaseStorage {
setMaxOperationRetryTime(time: number): void {
this._delegate.maxOperationRetryTime = time;
}

useEmulator(host: string, port: number): void {
internalUseEmulator(this._delegate, host, port);
}
}
19 changes: 18 additions & 1 deletion packages/storage/exp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
import { XhrIoPool } from '../src/implementation/xhriopool';
import {
ref as refInternal,
StorageService as StorageServiceInternal
StorageService as StorageServiceInternal,
useStorageEmulator as useEmulatorInternal
} from '../src/service';
import {
Component,
Expand Down Expand Up @@ -283,6 +284,22 @@ export function ref(
);
}

/**
* 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';

/**
Expand Down
12 changes: 7 additions & 5 deletions packages/storage/src/implementation/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,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.
Expand All @@ -67,7 +67,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.\\-_]+)';

Expand All @@ -84,7 +84,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}`,
Expand All @@ -93,7 +93,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}`,
Expand Down
5 changes: 3 additions & 2 deletions packages/storage/src/implementation/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions packages/storage/src/implementation/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -143,7 +147,7 @@ export function getMetadata(
mappings: Mappings
): RequestInfo<Metadata> {
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(
Expand Down Expand Up @@ -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(
Expand All @@ -199,7 +203,7 @@ export function getDownloadUrl(
mappings: Mappings
): RequestInfo<string | null> {
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(
Expand All @@ -219,7 +223,7 @@ export function updateMetadata(
mappings: Mappings
): RequestInfo<Metadata> {
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' };
Expand All @@ -241,7 +245,7 @@ export function deleteObject(
location: Location
): RequestInfo<void> {
const urlPart = location.fullServerUrl();
const url = makeUrl(urlPart);
const url = makeUrl(urlPart, service.host);
const method = 'DELETE';
const timeout = service.maxOperationRetryTime;

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 8 additions & 3 deletions packages/storage/src/implementation/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+):\/\/.+/);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this because emulator uses http when production use defaults to https. Seems cleaner to let the host string contain a protocol or not, and if so, parse it in this one function, than to store protocol as a separate property on StorageService and get it everywhere service.host is also used.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/optional, I think using replace with callback would save some lines and be more readable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't figure out how to easily set up a regex that would call the callback if the protocol wasn't there so I left it alone for now. We can always optimize any time so let me know if you have a snippet you want to put in.

const protocol = protocolMatch?.[1];
let origin = host;
if (protocol == null) {
origin = `https://${host}`;
}
return `${origin}/v0${urlPart}`;
}

export function makeQueryString(params: UrlParams): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/storage/src/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
47 changes: 42 additions & 5 deletions packages/storage/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`;
}

/**
Expand All @@ -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<Request<unknown>>;
private _deleted: boolean = false;
Expand All @@ -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);
}
}

Expand Down
Loading