Skip to content
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

feat(storage): Add getDownloadUrl method to the Storage API #2036

Merged
merged 34 commits into from
Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3cc7ea6
Added getDownloadUrl to storage spec
maneesht Jan 6, 2023
f617907
Removed unnecessary var assignment
maneesht Jan 6, 2023
0188a88
Updated api
maneesht Jan 6, 2023
44cbde7
Created download tokens if not available
maneesht Apr 12, 2023
a93c8d3
Addressed comments
maneesht Apr 13, 2023
925433f
Updated to fix endpoint
maneesht Apr 13, 2023
07d3fc2
Removed file
maneesht Apr 13, 2023
55362d5
Made storage modifiable
maneesht Apr 13, 2023
5ff2ec0
Updated to stop passing endpoint info around
maneesht Apr 18, 2023
d2cd1c0
Removed only
maneesht Apr 18, 2023
8581f91
Removed it only
maneesht Apr 18, 2023
784aec4
Modified to use top-level instead of extending gcs API
maneesht May 2, 2023
2ede36b
Addressed comments
maneesht May 3, 2023
8226d52
Updated docs
maneesht May 3, 2023
bcf7118
Merge remote-tracking branch 'origin/master' into mtewani/add-downloa…
maneesht May 3, 2023
d32c44d
Updated lockfile
maneesht May 3, 2023
be58f54
Updated storage integration test
maneesht May 8, 2023
a13b3e2
Updated md
maneesht May 8, 2023
712d95c
Merge branch 'master' into mtewani/add-download-link-storage
maneesht May 8, 2023
e39c051
Addresesd comments
maneesht Jun 1, 2023
b6efb5f
Reverted package-lock changes
maneesht Jun 1, 2023
116df89
Reverted from origin
maneesht Jun 1, 2023
21e9f04
Merge remote-tracking branch 'origin/master' into mtewani/add-downloa…
maneesht Jun 1, 2023
d718edf
Replaced in with of
maneesht Jun 1, 2023
89d1176
Updated env setting
maneesht Jun 1, 2023
8bfb21d
Fixed linting errors
maneesht Jun 1, 2023
3b7e2ff
Fixed test
maneesht Jun 5, 2023
5e89622
Undid fix
maneesht Jun 5, 2023
d64713b
Made sure tests worked
maneesht Jun 6, 2023
730c6f0
Removed extra env var
maneesht Jun 7, 2023
4443bf5
Addressed comments
maneesht Jun 7, 2023
54f130e
Deleted file ref
maneesht Jun 7, 2023
66f41b5
Fixed integration tests
maneesht Jun 7, 2023
a883e1b
Merge branch 'master' into mtewani/add-download-link-storage
maneesht Jun 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions etc/firebase-admin.storage.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

import { Agent } from 'http';
import { Bucket } from '@google-cloud/storage';
import { File } from '@google-cloud/storage';

// @public
export function getDownloadUrl(file: File): Promise<string>;

// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
//
Expand Down
35 changes: 35 additions & 0 deletions src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@
* @packageDocumentation
*/

import { File } from '@google-cloud/storage';
import { App, getApp } from '../app';
import { FirebaseApp } from '../app/firebase-app';
import { Storage } from './storage';
import { FirebaseError } from '../utils/error';
import { getFirebaseMetadata } from './utils';

export { Storage } from './storage';


/**
* Gets the {@link Storage} service for the default app or a given app.
*
Expand Down Expand Up @@ -53,3 +57,34 @@ export function getStorage(app?: App): Storage {
const firebaseApp: FirebaseApp = app as FirebaseApp;
return firebaseApp.getOrInitService('storage', (app) => new Storage(app));
}



/**
* Gets the download URL for the given {@link @google-cloud/storage#File}.
*
* @example
* ```javascript
* // Get the downloadUrl for a given file ref
* const storage = getStorage();
* const myRef = ref(storage, 'images/mountains.jpg');
* const downloadUrl = await getDownloadUrl(myRef);
* ```
*/
export async function getDownloadUrl(file: File): Promise<string> {
const endpoint =
(process.env.STORAGE_EMULATOR_HOST ||
'https://firebasestorage.googleapis.com') + '/v0';
const { downloadTokens } = await getFirebaseMetadata(endpoint, file);
if (!downloadTokens) {
throw new FirebaseError({
code: 'storage/no-download-token',
message:
'No download token available. Please create one in the Firebase Console.',
});
}
const [token] = downloadTokens.split(',');
return `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent(
file.name
)}?alt=media&token=${token}`;
}
1 change: 0 additions & 1 deletion src/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export class Storage {
'explicitly when calling the getBucket() method.',
});
}

/**
* Optional app whose `Storage` service to
* return. If not provided, the default `Storage` service will be returned.
Expand Down
43 changes: 43 additions & 0 deletions src/storage/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { File } from '@google-cloud/storage';
export interface FirebaseMetadata {
name: string;
bucket: string;
generation: string;
metageneration: string;
contentType: string;
timeCreated: string;
updated: string;
storageClass: string;
size: string;
md5Hash: string;
contentEncoding: string;
contentDisposition: string;
crc32c: string;
etag: string;
downloadTokens?: string;
}

export function getFirebaseMetadata(
endpoint: string,
file: File
): Promise<FirebaseMetadata> {
const uri = `${endpoint}/b/${file.bucket.name}/o/${encodeURIComponent(
file.name
)}`;

return new Promise((resolve, reject) => {
file.storage.makeAuthenticatedRequest(
{
method: 'GET',
uri,
},
(err, body) => {
if (err) {
reject(err);
} else {
resolve(body);
}
maneesht marked this conversation as resolved.
Show resolved Hide resolved
}
);
});
}
48 changes: 47 additions & 1 deletion test/integration/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ import * as chaiAsPromised from 'chai-as-promised';
import { Bucket, File } from '@google-cloud/storage';

import { projectId } from './setup';
import { getStorage } from '../../lib/storage/index';
import { getDownloadUrl, getStorage } from '../../lib/storage/index';
import { getFirebaseMetadata } from '../../src/storage/utils';
maneesht marked this conversation as resolved.
Show resolved Hide resolved
import { FirebaseError } from '../../src/utils/error';

chai.should();
chai.use(chaiAsPromised);

const expect = chai.expect;

describe('admin.storage', () => {
let currentRef: File | null = null;
afterEach(async () => {
if (currentRef) {
await currentRef.delete();
}
currentRef = null;
});
it('bucket() returns a handle to the default bucket', () => {
const bucket: Bucket = getStorage().bucket();
return verifyBucket(bucket, 'storage().bucket()')
Expand All @@ -39,13 +48,43 @@ describe('admin.storage', () => {
.should.eventually.be.fulfilled;
});
maneesht marked this conversation as resolved.
Show resolved Hide resolved

it('getDownloadUrl returns a download URL', async () => {
const bucket = getStorage().bucket(projectId + '.appspot.com');
currentRef = await verifyBucketDownloadUrl(bucket, 'testName');
// Note: For now, this generates a download token when needed, but in the future it may not.
const metadata = await getFirebaseMetadata(
'https://firebasestorage.googleapis.com/v0',
currentRef
);
if (!metadata.downloadTokens) {
expect(getDownloadUrl(currentRef)).to.eventually.throw(
new FirebaseError({
code: 'storage/invalid-argument',
message:
'Bucket name not specified or invalid. Specify a valid bucket name via the ' +
'storageBucket option when initializing the app, or specify the bucket name ' +
'explicitly when calling the getBucket() method.',
})
);
return;
}
const downloadUrl = await getDownloadUrl(currentRef);

const [token] = metadata.downloadTokens.split(',');
const storageEndpoint = `https://firebasestorage.googleapis.com/v0/b/${
bucket.name
}/o/${encodeURIComponent(currentRef.name)}?alt=media&token=${token}`;
expect(downloadUrl).to.equal(storageEndpoint);
});

it('bucket(non-existing) returns a handle which can be queried for existence', () => {
const bucket: Bucket = getStorage().bucket('non.existing');
return bucket.exists()
.then((data) => {
expect(data[0]).to.be.false;
});
});

});

function verifyBucket(bucket: Bucket, testName: string): Promise<void> {
Expand All @@ -66,3 +105,10 @@ function verifyBucket(bucket: Bucket, testName: string): Promise<void> {
expect(data[0], 'File not deleted').to.be.false;
});
}

async function verifyBucketDownloadUrl(bucket: Bucket, testName: string): Promise<File> {
const expected: string = 'Hello World: ' + testName;
const file: File = bucket.file('data_' + Date.now() + '.txt');
await file.save(expected)
return file;
}
4 changes: 2 additions & 2 deletions test/resources/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ export class MockComputeEngineCredential extends ComputeEngineCredential {
}
}

export function app(): FirebaseApp {
return new FirebaseApp(appOptions, appName);
export function app(altName?: string): FirebaseApp {
return new FirebaseApp(appOptions, altName || appName);
}

export function mockCredentialApp(): FirebaseApp {
Expand Down
83 changes: 79 additions & 4 deletions test/unit/storage/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@

import * as chai from 'chai';
import * as sinonChai from 'sinon-chai';
import { createSandbox, SinonSandbox } from 'sinon';
import * as chaiAsPromised from 'chai-as-promised';

import * as mocks from '../../resources/mocks';
import { App } from '../../../src/app/index';
import { getStorage, Storage } from '../../../src/storage/index';
import * as StorageUtils from '../../../src/storage/utils';
import { getStorage, Storage, getDownloadUrl } from '../../../src/storage/index';

chai.should();
chai.use(sinonChai);
Expand All @@ -35,13 +37,19 @@ describe('Storage', () => {
let mockApp: App;
let mockCredentialApp: App;

const noProjectIdError = 'Failed to initialize Google Cloud Storage client with the '
+ 'available credential. Must initialize the SDK with a certificate credential or '
+ 'application default credentials to use Cloud Storage API.';
const noProjectIdError =
'Failed to initialize Google Cloud Storage client with the ' +
'available credential. Must initialize the SDK with a certificate credential or ' +
'application default credentials to use Cloud Storage API.';

let sandbox: SinonSandbox;
beforeEach(() => {
mockApp = mocks.app();
mockCredentialApp = mocks.mockCredentialApp();
sandbox = createSandbox();
});
afterEach(() => {
sandbox.restore();
});

describe('getStorage()', () => {
Expand Down Expand Up @@ -69,5 +77,72 @@ describe('Storage', () => {
const storage2: Storage = getStorage(mockApp);
expect(storage1).to.equal(storage2);
});

it('should return an error when no metadata is available', async () => {
maneesht marked this conversation as resolved.
Show resolved Hide resolved
sandbox
.stub(StorageUtils, 'getFirebaseMetadata')
.returns(Promise.resolve({} as StorageUtils.FirebaseMetadata));
const storage1 = getStorage(mockApp);
const fileRef = storage1.bucket('gs://mock').file('abc');
await expect(getDownloadUrl(fileRef)).to.be.rejectedWith(
'No download token available. Please create one in the Firebase Console.'
);
});
maneesht marked this conversation as resolved.
Show resolved Hide resolved

it('should return an error when unable to fetch metadata', async () => {
const error = new Error('Could not get metadata');
sandbox
.stub(StorageUtils, 'getFirebaseMetadata')
.returns(Promise.reject(error));
const storage1 = getStorage(mockApp);
const fileRef = storage1.bucket('gs://mock').file('abc');
await expect(getDownloadUrl(fileRef)).to.be.rejectedWith(
error
);
});
it('should return the proper download url when metadata is available', async () => {
const downloadTokens = ['abc', 'def'];
sandbox
.stub(StorageUtils, 'getFirebaseMetadata')
.returns(
Promise.resolve({
downloadTokens: downloadTokens.join(','),
} as StorageUtils.FirebaseMetadata)
);
const storage1 = getStorage(mockApp);
const fileRef = storage1.bucket('gs://mock').file('abc');
await expect(getDownloadUrl(fileRef)).to.eventually.eq(
`https://firebasestorage.googleapis.com/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent(fileRef.name)}?alt=media&token=${downloadTokens[0]}`
);
});
it('should use the emulator host name when either envs are set', async () => {
const HOST = 'localhost:9091';
const envsToCheck = [
{ envName: 'FIREBASE_STORAGE_EMULATOR_HOST', value: HOST },
{ envName: 'STORAGE_EMULATOR_HOST', value: `http://${HOST}` },
];
const downloadTokens = ['abc', 'def'];
sandbox.stub(StorageUtils, 'getFirebaseMetadata').returns(
Promise.resolve({
downloadTokens: downloadTokens.join(','),
} as StorageUtils.FirebaseMetadata)
);
for (const { envName, value } of envsToCheck) {

delete process.env.STORAGE_EMULATOR_HOST;
delete process.env[envName];
process.env[envName] = value;

// Need to create a new mock app to force `getStorage`'s checking of env vars.
const storage1 = getStorage(mocks.app(envName));
const fileRef = storage1.bucket('gs://mock').file('abc');
await expect(getDownloadUrl(fileRef)).to.eventually.eq(
`http://${HOST}/v0/b/${fileRef.bucket.name}/o/${encodeURIComponent(
fileRef.name
)}?alt=media&token=${downloadTokens[0]}`
);
delete process.env[envName];
}
});
});
});