Skip to content

Commit aed9148

Browse files
authored
feat(storage): Add getDownloadUrl method to the Storage API (#2036)
1 parent cf85bd8 commit aed9148

File tree

7 files changed

+210
-8
lines changed

7 files changed

+210
-8
lines changed

etc/firebase-admin.storage.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
import { Agent } from 'http';
1010
import { Bucket } from '@google-cloud/storage';
11+
import { File } from '@google-cloud/storage';
12+
13+
// @public
14+
export function getDownloadUrl(file: File): Promise<string>;
1115

1216
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
1317
//

src/storage/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@
2020
* @packageDocumentation
2121
*/
2222

23+
import { File } from '@google-cloud/storage';
2324
import { App, getApp } from '../app';
2425
import { FirebaseApp } from '../app/firebase-app';
2526
import { Storage } from './storage';
27+
import { FirebaseError } from '../utils/error';
28+
import { getFirebaseMetadata } from './utils';
2629

2730
export { Storage } from './storage';
2831

32+
2933
/**
3034
* Gets the {@link Storage} service for the default app or a given app.
3135
*
@@ -53,3 +57,34 @@ export function getStorage(app?: App): Storage {
5357
const firebaseApp: FirebaseApp = app as FirebaseApp;
5458
return firebaseApp.getOrInitService('storage', (app) => new Storage(app));
5559
}
60+
61+
62+
63+
/**
64+
* Gets the download URL for the given {@link @google-cloud/storage#File}.
65+
*
66+
* @example
67+
* ```javascript
68+
* // Get the downloadUrl for a given file ref
69+
* const storage = getStorage();
70+
* const myRef = ref(storage, 'images/mountains.jpg');
71+
* const downloadUrl = await getDownloadUrl(myRef);
72+
* ```
73+
*/
74+
export async function getDownloadUrl(file: File): Promise<string> {
75+
const endpoint =
76+
(process.env.STORAGE_EMULATOR_HOST ||
77+
'https://firebasestorage.googleapis.com') + '/v0';
78+
const { downloadTokens } = await getFirebaseMetadata(endpoint, file);
79+
if (!downloadTokens) {
80+
throw new FirebaseError({
81+
code: 'storage/no-download-token',
82+
message:
83+
'No download token available. Please create one in the Firebase Console.',
84+
});
85+
}
86+
const [token] = downloadTokens.split(',');
87+
return `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent(
88+
file.name
89+
)}?alt=media&token=${token}`;
90+
}

src/storage/storage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ export class Storage {
117117
'explicitly when calling the getBucket() method.',
118118
});
119119
}
120-
121120
/**
122121
* Optional app whose `Storage` service to
123122
* return. If not provided, the default `Storage` service will be returned.

src/storage/utils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { File } from '@google-cloud/storage';
2+
export interface FirebaseMetadata {
3+
name: string;
4+
bucket: string;
5+
generation: string;
6+
metageneration: string;
7+
contentType: string;
8+
timeCreated: string;
9+
updated: string;
10+
storageClass: string;
11+
size: string;
12+
md5Hash: string;
13+
contentEncoding: string;
14+
contentDisposition: string;
15+
crc32c: string;
16+
etag: string;
17+
downloadTokens?: string;
18+
}
19+
20+
export function getFirebaseMetadata(
21+
endpoint: string,
22+
file: File
23+
): Promise<FirebaseMetadata> {
24+
const uri = `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent(
25+
file.name
26+
)}`;
27+
28+
return new Promise((resolve, reject) => {
29+
file.storage.makeAuthenticatedRequest(
30+
{
31+
method: 'GET',
32+
uri,
33+
},
34+
(err, body) => {
35+
if (err) {
36+
reject(err);
37+
} else {
38+
resolve(body);
39+
}
40+
}
41+
);
42+
});
43+
}

test/integration/storage.spec.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,23 @@ import * as chaiAsPromised from 'chai-as-promised';
1919
import { Bucket, File } from '@google-cloud/storage';
2020

2121
import { projectId } from './setup';
22-
import { getStorage } from '../../lib/storage/index';
22+
import { getDownloadUrl, getStorage } from '../../lib/storage/index';
23+
import { getFirebaseMetadata } from '../../src/storage/utils';
24+
import { FirebaseError } from '../../src/utils/error';
2325

2426
chai.should();
2527
chai.use(chaiAsPromised);
2628

2729
const expect = chai.expect;
2830

2931
describe('admin.storage', () => {
32+
let currentRef: File | null = null;
33+
afterEach(async () => {
34+
if (currentRef) {
35+
await currentRef.delete();
36+
}
37+
currentRef = null;
38+
});
3039
it('bucket() returns a handle to the default bucket', () => {
3140
const bucket: Bucket = getStorage().bucket();
3241
return verifyBucket(bucket, 'storage().bucket()')
@@ -39,13 +48,43 @@ describe('admin.storage', () => {
3948
.should.eventually.be.fulfilled;
4049
});
4150

51+
it('getDownloadUrl returns a download URL', async () => {
52+
const bucket = getStorage().bucket(projectId + '.appspot.com');
53+
currentRef = await verifyBucketDownloadUrl(bucket, 'testName');
54+
// Note: For now, this generates a download token when needed, but in the future it may not.
55+
const metadata = await getFirebaseMetadata(
56+
'https://firebasestorage.googleapis.com/v0',
57+
currentRef
58+
);
59+
if (!metadata.downloadTokens) {
60+
expect(getDownloadUrl(currentRef)).to.eventually.throw(
61+
new FirebaseError({
62+
code: 'storage/invalid-argument',
63+
message:
64+
'Bucket name not specified or invalid. Specify a valid bucket name via the ' +
65+
'storageBucket option when initializing the app, or specify the bucket name ' +
66+
'explicitly when calling the getBucket() method.',
67+
})
68+
);
69+
return;
70+
}
71+
const downloadUrl = await getDownloadUrl(currentRef);
72+
73+
const [token] = metadata.downloadTokens.split(',');
74+
const storageEndpoint = `https://firebasestorage.googleapis.com/v0/b/${
75+
bucket.name
76+
}/o/${encodeURIComponent(currentRef.name)}?alt=media&token=${token}`;
77+
expect(downloadUrl).to.equal(storageEndpoint);
78+
});
79+
4280
it('bucket(non-existing) returns a handle which can be queried for existence', () => {
4381
const bucket: Bucket = getStorage().bucket('non.existing');
4482
return bucket.exists()
4583
.then((data) => {
4684
expect(data[0]).to.be.false;
4785
});
4886
});
87+
4988
});
5089

5190
function verifyBucket(bucket: Bucket, testName: string): Promise<void> {
@@ -66,3 +105,10 @@ function verifyBucket(bucket: Bucket, testName: string): Promise<void> {
66105
expect(data[0], 'File not deleted').to.be.false;
67106
});
68107
}
108+
109+
async function verifyBucketDownloadUrl(bucket: Bucket, testName: string): Promise<File> {
110+
const expected: string = 'Hello World: ' + testName;
111+
const file: File = bucket.file('data_' + Date.now() + '.txt');
112+
await file.save(expected)
113+
return file;
114+
}

test/resources/mocks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ export class MockComputeEngineCredential extends ComputeEngineCredential {
104104
}
105105
}
106106

107-
export function app(): FirebaseApp {
108-
return new FirebaseApp(appOptions, appName);
107+
export function app(altName?: string): FirebaseApp {
108+
return new FirebaseApp(appOptions, altName || appName);
109109
}
110110

111111
export function mockCredentialApp(): FirebaseApp {

test/unit/storage/index.spec.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919

2020
import * as chai from 'chai';
2121
import * as sinonChai from 'sinon-chai';
22+
import { createSandbox, SinonSandbox } from 'sinon';
2223
import * as chaiAsPromised from 'chai-as-promised';
2324

2425
import * as mocks from '../../resources/mocks';
2526
import { App } from '../../../src/app/index';
26-
import { getStorage, Storage } from '../../../src/storage/index';
27+
import * as StorageUtils from '../../../src/storage/utils';
28+
import { getStorage, Storage, getDownloadUrl } from '../../../src/storage/index';
2729

2830
chai.should();
2931
chai.use(sinonChai);
@@ -35,13 +37,19 @@ describe('Storage', () => {
3537
let mockApp: App;
3638
let mockCredentialApp: App;
3739

38-
const noProjectIdError = 'Failed to initialize Google Cloud Storage client with the '
39-
+ 'available credential. Must initialize the SDK with a certificate credential or '
40-
+ 'application default credentials to use Cloud Storage API.';
40+
const noProjectIdError =
41+
'Failed to initialize Google Cloud Storage client with the ' +
42+
'available credential. Must initialize the SDK with a certificate credential or ' +
43+
'application default credentials to use Cloud Storage API.';
4144

45+
let sandbox: SinonSandbox;
4246
beforeEach(() => {
4347
mockApp = mocks.app();
4448
mockCredentialApp = mocks.mockCredentialApp();
49+
sandbox = createSandbox();
50+
});
51+
afterEach(() => {
52+
sandbox.restore();
4553
});
4654

4755
describe('getStorage()', () => {
@@ -69,5 +77,72 @@ describe('Storage', () => {
6977
const storage2: Storage = getStorage(mockApp);
7078
expect(storage1).to.equal(storage2);
7179
});
80+
81+
it('should return an error when no metadata is available', async () => {
82+
sandbox
83+
.stub(StorageUtils, 'getFirebaseMetadata')
84+
.returns(Promise.resolve({} as StorageUtils.FirebaseMetadata));
85+
const storage1 = getStorage(mockApp);
86+
const fileRef = storage1.bucket('gs://mock').file('abc');
87+
await expect(getDownloadUrl(fileRef)).to.be.rejectedWith(
88+
'No download token available. Please create one in the Firebase Console.'
89+
);
90+
});
91+
92+
it('should return an error when unable to fetch metadata', async () => {
93+
const error = new Error('Could not get metadata');
94+
sandbox
95+
.stub(StorageUtils, 'getFirebaseMetadata')
96+
.returns(Promise.reject(error));
97+
const storage1 = getStorage(mockApp);
98+
const fileRef = storage1.bucket('gs://mock').file('abc');
99+
await expect(getDownloadUrl(fileRef)).to.be.rejectedWith(
100+
error
101+
);
102+
});
103+
it('should return the proper download url when metadata is available', async () => {
104+
const downloadTokens = ['abc', 'def'];
105+
sandbox
106+
.stub(StorageUtils, 'getFirebaseMetadata')
107+
.returns(
108+
Promise.resolve({
109+
downloadTokens: downloadTokens.join(','),
110+
} as StorageUtils.FirebaseMetadata)
111+
);
112+
const storage1 = getStorage(mockApp);
113+
const fileRef = storage1.bucket('gs://mock').file('abc');
114+
await expect(getDownloadUrl(fileRef)).to.eventually.eq(
115+
`https://firebasestorage.googleapis.com/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent(fileRef.name)}?alt=media&token=${downloadTokens[0]}`
116+
);
117+
});
118+
it('should use the emulator host name when either envs are set', async () => {
119+
const HOST = 'localhost:9091';
120+
const envsToCheck = [
121+
{ envName: 'FIREBASE_STORAGE_EMULATOR_HOST', value: HOST },
122+
{ envName: 'STORAGE_EMULATOR_HOST', value: `http://${HOST}` },
123+
];
124+
const downloadTokens = ['abc', 'def'];
125+
sandbox.stub(StorageUtils, 'getFirebaseMetadata').returns(
126+
Promise.resolve({
127+
downloadTokens: downloadTokens.join(','),
128+
} as StorageUtils.FirebaseMetadata)
129+
);
130+
for (const { envName, value } of envsToCheck) {
131+
132+
delete process.env.STORAGE_EMULATOR_HOST;
133+
delete process.env[envName];
134+
process.env[envName] = value;
135+
136+
// Need to create a new mock app to force `getStorage`'s checking of env vars.
137+
const storage1 = getStorage(mocks.app(envName));
138+
const fileRef = storage1.bucket('gs://mock').file('abc');
139+
await expect(getDownloadUrl(fileRef)).to.eventually.eq(
140+
`http://${HOST}/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent(
141+
fileRef.name
142+
)}?alt=media&token=${downloadTokens[0]}`
143+
);
144+
delete process.env[envName];
145+
}
146+
});
72147
});
73148
});

0 commit comments

Comments
 (0)