Skip to content

Instantly share code, notes, and snippets.

@grahamlyus
Created August 27, 2017 02:14
Show Gist options
  • Save grahamlyus/e6b0facf6a718a31449dec3ee77c8060 to your computer and use it in GitHub Desktop.
Save grahamlyus/e6b0facf6a718a31449dec3ee77c8060 to your computer and use it in GitHub Desktop.
import firebase from "firebase";
import uuidv1 from "uuid/v1"
// Wrapper to enable mocking of firebase functions
export const _private = {
storageRefForPath: (path) => firebase.storage().ref().child(path)
}
/**
* Uploads the a file for a resource field.
*
* A filename is generated from a uuid and the file type,
* and it is uploaded to the path `resource/field/filename`.
*
* @param {Object} options
* @param {string} options.resource Resource name, e.g. `posts`
* @param {string} options.field Field name, e.g. `image`
* @param {File} options.file File object to upload
* @returns {Promise} e.g. { field, path, url }
*/
export const uploadFile = async ({resource, field, file}) => {
const extension = file.type.split('/')[1]
const filename = `${uuidv1()}.${extension}`
const path = `${resource}/${field}/${filename}`
console.log(`Upload starting for ${path}...`)
const ref = _private.storageRefForPath(path)
const snapshot = await ref.put(file)
console.log(`Upload finished for ${path}`)
const url = snapshot.downloadURL
return { field, path, url }
}
/**
* Uploads a list of files and maps field name to { path, url }
*
* @param {Object[]} options
* @param {string} options.resource Resource name, e.g. `posts`
* @param {string} options.field Field name, e.g. `image`
* @param {File} options.file File object to upload
* @returns {Promise} e.g. { image: { path, url }, audio: { path, url } }
*/
export const uploadFiles = async (files) => {
const uploads = await Promise.all(files.map(uploadFile))
return uploads.reduce((obj, { field, ...value }) => ({ ...obj, [field]: value }), {})
}
/**
* Delete the file at path.
*
* @param {string} path Path to the file in the default bucket
* @returns {Promise}
*/
export const deleteFile = async (path) => {
const ref = _private.storageRefForPath(path)
console.log(`Delete starting for ${path}...`)
const result = await ref.delete()
console.log(`Delete finished for ${path}`)
return result
}
/**
* Delete a list of files.
*
* @param {Object[]} files
* @param {string} files.data.path Path to the file in the default bucket
* @returns {Promise[]}
*/
export const deleteFiles = async (files) => {
return await Promise.all(files.map(file => deleteFile(file.data.path)))
}
/**
* Find the first new file within a field i.e. with a `rawFile` property.
*
* @param {Object} data Field data may be an array of Files
* @returns {File} the first File object
*/
const findFirstNewFile = (data) => data && data.length && data.find(o => o instanceof File)
/**
* Find the fields with new files.
*
* @param {string} resource Resource name, e.g. `posts`
* @param {string[]} fields File field names
* @param {Object} data Form field data
* @returns {Object[]} [{resource, field, file}] filtered files
*/
export const findFilesToUpload = (resource, fields, data) => {
return fields
.filter(field => findFirstNewFile(data[field]))
.map(field => ({resource, field, file: findFirstNewFile(data[field])}))
}
/**
* Find the fields with existing files i.e. with a `path` property.
*
* @param {string[]} fields File field names
* @param {Object} data Field data
* @returns {Object[]} [{field, data: {path, url}}] filtered files
*/
export const findExistingFiles = (fields, data) => {
return fields
.filter(field => data[field] && data[field].path)
.map(field => ({field, data: data[field]}))
}
/**
* Handles a `CREATE` operation by uploading new file fields and then
* delegating to the requestHandler as { path, url }.
*
* @param {Function} requestHandler original RestClient request handler
* @param {Object} fields file fields for each resource e.g. {posts: ['image']}
* @param {Object} type Operation type, i.e. `CREATE`
* @param {string} resource Resource name, e.g. `posts`
* @param {Object} params RestClient params
*/
const handleCreate = async (requestHandler, fields, type, resource, params) => {
const filesToUpload = findFilesToUpload(resource, fields, params.data)
const uploadedFiles = await uploadFiles(filesToUpload)
return requestHandler(type, resource, {
...params,
data: {
...params.data,
...uploadedFiles
},
})
}
/**
* Handles an `UPDATE` operation by uploading new file fields and deleting
* replaced files and delegating to the requestHandler as { path, url }.
*
* @param {Function} requestHandler original RestClient request handler
* @param {Object} fields file fields for each resource e.g. {posts: ['image']}
* @param {Object} type Operation type, i.e. `UPDATE`
* @param {string} resource Resource name, e.g. `posts`
* @param {Object} params RestClient params
*/
const handleUpdate = async (requestHandler, fields, type, resource, params) => {
const filesToUpload = findFilesToUpload(resource, fields, params.data)
const fieldsWithFilesToUpload = filesToUpload.map(file => file.field)
const existingFiles = findExistingFiles(fields, params.previousData)
const filesToDelete = existingFiles.filter(file => fieldsWithFilesToUpload.includes(file.field))
const [uploadedFiles] = await Promise.all([
uploadFiles(filesToUpload),
deleteFiles(filesToDelete)
])
return requestHandler(type, resource, {
...params,
data: {
...params.data,
// ...deletedFiles // TODO remove old refs???
...uploadedFiles
},
})
}
/**
* Handles a `DELETE` operation by deleting files before delegating to
* the requestHandler.
*
* @param {Function} requestHandler original RestClient request handler
* @param {Object} fields file fields for each resource e.g. {posts: ['image']}
* @param {Object} type Operation type, i.e. `DELETE`
* @param {string} resource Resource name, e.g. `posts`
* @param {Object} params RestClient params
*/
const handleDelete = async (requestHandler, fields, type, resource, params) => {
const filesToDelete = findExistingFiles(fields, params.previousData)
await deleteFiles(filesToDelete)
return requestHandler(type, resource, params)
}
// Map of handlers.
const HANDLERS = {
CREATE: handleCreate,
UPDATE: handleUpdate,
DELETE: handleDelete
}
/**
* Wraps the rest client to handle file fields.
* File fields are uploaded to the default Firebase storage bucket - see `uploadFile`
* The value written to the database field is `{ path, url }`,
* where `url` is the `downloadURL` retrieved from the storage snapshot.
* Old files are removed on `UPDATE` and `DELETE`.
*/
const restClientFileWrapper = (requestHandler, fileFields) => async (type, resource, params) => {
const fields = fileFields[resource]
const handler = HANDLERS[type]
if (fields && handler) {
return handler(requestHandler, fields, type, resource, params)
}
return requestHandler(type, resource, params)
}
export default restClientFileWrapper
import uuidv1 from 'uuid/v1'
import * as module from './restClientFileWrapper'
const RESOURCE = 'post'
const CREATE = 'CREATE'
const UPDATE = 'UPDATE'
const DELETE = 'DELETE'
const GET_ONE = 'GET_ONE'
const FILE_FIELDS = ['audio', 'image']
const DOWNLOAD_URL = 'https://www.example.com/uuid.mp3'
const PLAIN_DATA = {
title: 'adlkjasldj',
description: 'dkkhfksdhfk'
}
const NEW_AUDIO_FILE = new File(['foo'], 'foo.mp3', {type: 'audio/mp3'})
const NEW_IMAGE_FILE = new File(['foo'], 'foo.png', {type: 'image/png'})
const OLD_AUDIO_FILE = {path: `${RESOURCE}/audio/${uuidv1()}.mp3`, url: DOWNLOAD_URL}
const OLD_IMAGE_FILE = {path: `${RESOURCE}/image/${uuidv1()}.png`, url: DOWNLOAD_URL}
const MOCK_REF = {
delete: () => Promise.resolve(),
put: () => Promise.resolve({downloadURL: DOWNLOAD_URL})
}
const UUID_REGEX = '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}'
const AUDIO_FILE_REGEX = `^${RESOURCE}/audio/${UUID_REGEX}\.mp3$`
const IMAGE_FILE_REGEX = `^${RESOURCE}/image/${UUID_REGEX}\.png$`
describe.only("restClientFileWrapper", () => {
let putSpy, deleteSpy
beforeEach(() => {
module._private.storageRefForPath = jest.fn()
module._private.storageRefForPath.mockReturnValue(MOCK_REF)
deleteSpy = jest.spyOn(MOCK_REF, 'delete')
putSpy = jest.spyOn(MOCK_REF, 'put')
})
afterEach(() => {
module._private.storageRefForPath.mockReset()
deleteSpy.mockRestore()
putSpy.mockRestore()
})
describe("uploadFile", () => {
it("is a function", () => {
expect(module.uploadFile).toBeInstanceOf(Function)
})
it("builds a correct path", async () => {
const result = await module.uploadFile({
resource: RESOURCE,
field: 'audio',
file: NEW_AUDIO_FILE
})
expect(putSpy).toHaveBeenCalledTimes(1)
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(AUDIO_FILE_REGEX))
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1)
expect(result).toEqual({
field: 'audio',
path: expect.stringMatching(AUDIO_FILE_REGEX),
url: DOWNLOAD_URL
})
})
})
describe("uploadFiles", () => {
it("is a function", () => {
expect(module.uploadFiles).toBeInstanceOf(Function)
})
it("returns a map of files uploaded", async () => {
const result = await module.uploadFiles([{
resource: RESOURCE,
field: 'audio',
file: NEW_AUDIO_FILE
}, {
resource: RESOURCE,
field: 'image',
file: NEW_IMAGE_FILE
}])
expect(putSpy).toHaveBeenCalledTimes(2)
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(AUDIO_FILE_REGEX))
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX))
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2)
expect(result).toEqual({
audio: {
path: expect.stringMatching(AUDIO_FILE_REGEX),
url: DOWNLOAD_URL
},
image: {
path: expect.stringMatching(IMAGE_FILE_REGEX),
url: DOWNLOAD_URL
}
})
})
})
describe("deleteFile", () => {
it("is a function", () => {
expect(module.deleteFile).toBeInstanceOf(Function)
})
it("deletes the file", async () => {
const result = await module.deleteFile(OLD_AUDIO_FILE.path)
expect(deleteSpy).toHaveBeenCalledTimes(1)
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path)
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1)
expect(result).toBeUndefined()
})
})
describe("deleteFiles", () => {
it("is a function", () => {
expect(module.deleteFiles).toBeInstanceOf(Function)
})
it("deletes files", async () => {
const result = await module.deleteFiles([
{data: OLD_AUDIO_FILE},
{data: OLD_IMAGE_FILE}
])
expect(deleteSpy).toHaveBeenCalledTimes(2)
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path)
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path)
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2)
expect(result).toEqual([undefined, undefined])
})
})
describe("findFilesToUpload", () => {
it("is a function", () => {
expect(module.findFilesToUpload).toBeInstanceOf(Function)
})
it("returns no files if no files are present", () => {
const result = module.findFilesToUpload(RESOURCE, FILE_FIELDS, PLAIN_DATA)
expect(result).toHaveLength(0)
})
it("returns files if files are present", () => {
const result = module.findFilesToUpload(RESOURCE, FILE_FIELDS, {
...PLAIN_DATA,
audio: [NEW_AUDIO_FILE],
image: [NEW_IMAGE_FILE]
})
expect(result).toHaveLength(2)
expect(result).toEqual([
{resource: RESOURCE, field: 'audio', file: NEW_AUDIO_FILE},
{resource: RESOURCE, field: 'image', file: NEW_IMAGE_FILE}
])
})
})
describe("findExistingFiles", () => {
it("is a function", () => {
expect(module.findExistingFiles).toBeInstanceOf(Function)
})
it("returns no files if no existing files are present", () => {
const result = module.findExistingFiles(FILE_FIELDS, {
title: 'adlkjasldj',
description: 'dkkhfksdhfk'
})
expect(result).toHaveLength(0)
})
it("returns files if existing files are present", () => {
const result = module.findExistingFiles(FILE_FIELDS, {
...PLAIN_DATA,
audio: OLD_AUDIO_FILE,
image: [NEW_IMAGE_FILE]
})
expect(result).toHaveLength(1)
expect(result).toEqual([
{field: 'audio', data: OLD_AUDIO_FILE}
])
})
})
describe("restClientFileWrapper", () => {
const requestHandler = jest.fn()
const wrapper = module.default(requestHandler, {[RESOURCE]: FILE_FIELDS})
afterEach(() => {
requestHandler.mockReset()
})
it("is a function", () => {
expect(module.default).toBeInstanceOf(Function)
})
describe("CREATE", () => {
it("uses the default handler if no files are present", async () => {
const params = {
basePath: '/posts',
data: PLAIN_DATA
}
await wrapper(CREATE, RESOURCE, params)
expect(requestHandler).toBeCalledWith(CREATE, RESOURCE, params)
})
it("uploads and replaces the files with path/url pairs", async () => {
const params = {
basePath: '/posts',
data: {
...PLAIN_DATA,
image: [NEW_IMAGE_FILE]
}
}
await wrapper(CREATE, RESOURCE, params)
expect(deleteSpy).not.toHaveBeenCalled()
expect(putSpy).toHaveBeenCalledTimes(1)
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(1)
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX))
expect(requestHandler).toBeCalledWith(CREATE, RESOURCE, {
basePath: '/posts',
data: {
...PLAIN_DATA,
image: {
path: expect.stringMatching(IMAGE_FILE_REGEX),
url: DOWNLOAD_URL
}
}
})
})
})
describe("UPDATE", () => {
it("uses the default handler if no files are present", async () => {
const params = {
basePath: '/posts',
data: PLAIN_DATA,
previousData: {
...PLAIN_DATA,
title: 'old title'
}
}
await wrapper(UPDATE, RESOURCE, params)
expect(requestHandler).toBeCalledWith(UPDATE, RESOURCE, params)
})
it("replaces any updated files", async () => {
const params = {
basePath: '/posts',
data: {
...PLAIN_DATA,
audio: OLD_AUDIO_FILE,
image: [NEW_IMAGE_FILE]
},
previousData: {
...PLAIN_DATA,
audio: OLD_AUDIO_FILE,
image: OLD_IMAGE_FILE
}
}
await wrapper(UPDATE, RESOURCE, params)
expect(deleteSpy).toHaveBeenCalledTimes(1)
expect(putSpy).toHaveBeenCalledTimes(1)
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path)
expect(module._private.storageRefForPath).toBeCalledWith(expect.stringMatching(IMAGE_FILE_REGEX))
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2)
expect(requestHandler).toBeCalledWith(UPDATE, RESOURCE, {
...params,
data: {
...params.data,
image: {
path: expect.stringMatching(IMAGE_FILE_REGEX),
url: DOWNLOAD_URL
}
}
})
})
})
describe("DELETE", () => {
it("uses the default handler if no files are present", async () => {
const params = {
basePath: '/posts',
previousData: PLAIN_DATA
}
await wrapper(DELETE, RESOURCE, params)
expect(requestHandler).toBeCalledWith(DELETE, RESOURCE, params)
})
it("deletes any present files", async () => {
const params = {
basePath: '/posts',
previousData: {
...PLAIN_DATA,
audio: OLD_AUDIO_FILE,
image: OLD_IMAGE_FILE
}
}
await wrapper(DELETE, RESOURCE, params)
expect(deleteSpy).toHaveBeenCalledTimes(2)
expect(putSpy).not.toHaveBeenCalled()
expect(module._private.storageRefForPath).toBeCalledWith(OLD_IMAGE_FILE.path)
expect(module._private.storageRefForPath).toBeCalledWith(OLD_AUDIO_FILE.path)
expect(module._private.storageRefForPath).toHaveBeenCalledTimes(2)
expect(requestHandler).toBeCalledWith(DELETE, RESOURCE, params)
})
})
describe("GET_ONE", () => {
it("uses the default handler", async () => {
const params = {
id: '123'
}
await wrapper(GET_ONE, RESOURCE, params)
expect(requestHandler).toBeCalledWith(GET_ONE, RESOURCE, params)
})
})
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment