Skip to content

Commit

Permalink
feat: add option to change path of stored file
Browse files Browse the repository at this point in the history
- improve validations
- improve example that it can upload multiple files per model
- update readme
  • Loading branch information
wojtek-krysiak committed Oct 9, 2020
1 parent 90bbb2b commit 29572e5
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 33 deletions.
9 changes: 8 additions & 1 deletion example-app/src/admin/resources/multi/multi.resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import { Multi } from '../../../multi/multi.entity'
const photoProperties = {
bucket: {
type: 'string',
isVisible: false,
},
mime: {
type: 'string',
isVisible: false,
},
key: {
type: 'string',
isVisible: false,
},
size: {
type: 'number',
isVisible: false,
},
}

Expand All @@ -28,12 +32,15 @@ const uploadFeatureFor = (name?: string) => (
},
properties: {
file: name ? `${name}.file` : 'file',
filePath: name ? `${name}.file` : 'file',
filePath: name ? `${name}.filePath` : 'filePath',
key: name ? `${name}.key` : 'key',
mimeType: name ? `${name}.mime` : 'mime',
bucket: name ? `${name}.bucket` : 'bucket',
size: name ? `${name}.size` : 'size',
},
uploadPath: (record, filename) => (
name ? `${record.id()}/${name}/${filename}` : `${record.id}/global/${filename}`
),
})
)

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
}
},
"peerDependencies": {
"admin-bro": ">=3.2.5"
"admin-bro": ">=3.3.0-beta.20"
},
"optionalDependencies": {
"aws-sdk": "^2.728.0",
Expand All @@ -38,7 +38,7 @@
"@types/sinon-chai": "^3.2.4",
"@typescript-eslint/eslint-plugin": "^3.7.0",
"@typescript-eslint/parser": "^3.7.0",
"admin-bro": ">=3.2.5",
"admin-bro": ">=3.3.0-beta.20",
"aws-sdk": "^2.728.0",
"chai": "^4.2.0",
"eslint": "^7.5.0",
Expand Down
43 changes: 43 additions & 0 deletions src/features/upload-file/build-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { BaseRecord, UploadedFile } from 'admin-bro'
import { expect } from 'chai'
import sinon, { createStubInstance } from 'sinon'

import { buildRemotePath } from './build-remote-path'

describe('buildPath', () => {
let recordStub: BaseRecord
const recordId = '1'
const File: UploadedFile = {
name: 'some-name.pdf',
path: '/some-path.pdf',
size: 111,
type: 'txt',
}

after(() => {
sinon.restore()
})

before(() => {
recordStub = createStubInstance(BaseRecord, {
id: sinon.stub<any, string>().returns(recordId),
isValid: sinon.stub<any, boolean>().returns(true),
update: sinon.stub<any, Promise<BaseRecord>>().returnsThis(),
})
recordStub.params = {}
})

it('returns default path when no custom function is given', () => {
expect(buildRemotePath(recordStub, File)).to.equal(`${recordId}/${File.name}`)
})

it('returns default custom path when function is given', () => {
const newPath = '1/1/filename'
const fnStub = sinon.stub<[BaseRecord, string], string>().returns(newPath)

const path = buildRemotePath(recordStub, File, fnStub)

expect(path).to.equal(newPath)
expect(fnStub).to.have.been.calledWith(recordStub, File.name)
})
})
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import path from 'path'
import { BaseRecord, UploadedFile } from 'admin-bro'
import { ERROR_MESSAGES } from './constants'
import { UploadPathFunction } from './upload-options.type'

/**
* Creates a path to the file. Related to the given provider. If it is an AWS
* path is related to the bucket.
*
* @param {BaseRecord} record
* @param {string} path file path
* @param {UploadPathFunction} [pathFunction]
*
* @return {string}
* @private
*/
const buildRemotePath = (
export const buildRemotePath = (
record: BaseRecord,
file: UploadedFile,
uploadPathFunction?: UploadPathFunction,
): string => {
if (!record.id()) {
throw new Error(ERROR_MESSAGES.NO_PERSISTENT_RECORD_UPLOAD)
Expand All @@ -24,7 +27,9 @@ const buildRemotePath = (
}
const { ext, name } = path.parse(file.name)

if (uploadPathFunction) {
return uploadPathFunction(record, `${name}${ext}`)
}

return `${record.id()}/${name}${ext}`
}

export default buildRemotePath
15 changes: 15 additions & 0 deletions src/features/upload-file/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export const DAY_IN_MINUTES = 86400

export type DuplicateOccurrence = {
keys: Array<string>,
value: string
}

export const ERROR_MESSAGES = {
NO_PROVIDER: 'You have to specify provider in options',
WRONG_PROVIDER_OPTIONS: [
Expand All @@ -16,4 +21,14 @@ export const ERROR_MESSAGES = {
METHOD_NOT_IMPLEMENTED: (method: string): string => (
`you have to implement "${method}" method`
),
DUPLICATED_KEYS: (keys: Array<DuplicateOccurrence>): string => {
const mergedKeys = keys.map((duplicate) => (
` - "keys: ${duplicate.keys
.map((k) => `"${k}"`)
.join(', ')
}" have the same value: "${duplicate.value}",`
)).join('\n')

return `The same value for properties:\n${mergedKeys}`
},
}
9 changes: 6 additions & 3 deletions src/features/upload-file/upload-file.feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ import AdminBro, {

import { ERROR_MESSAGES } from './constants'
import { getProvider } from './get-provider'
import buildPath from './build-path'
import { buildRemotePath } from './build-remote-path'
import { BaseProvider } from './providers'
import UploadOptions from './upload-options.type'
import PropertyCustom from './property-custom.type'
import { validateProperties } from './validate-properties'

export type ProviderOptions = Required<Exclude<UploadOptions['provider'], BaseProvider>>

const uploadFileFeature = (config: UploadOptions): FeatureType => {
const { provider: providerOptions, properties, validation } = config
const { provider: providerOptions, properties, validation, uploadPath } = config

const { provider, name: providerName } = getProvider(providerOptions)

validateProperties(properties)

if (!properties.key) {
throw new Error(ERROR_MESSAGES.NO_KEY_PROPERTY)
}
Expand Down Expand Up @@ -83,7 +86,7 @@ const uploadFileFeature = (config: UploadOptions): FeatureType => {
const uploadedFile: UploadedFile = file

const oldRecordParams = { ...record.params }
const key = buildPath(record, uploadedFile)
const key = buildRemotePath(record, uploadedFile, uploadPath)

await provider.upload(uploadedFile, key, context)

Expand Down
27 changes: 27 additions & 0 deletions src/features/upload-file/upload-options.type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BaseRecord } from 'admin-bro'
import { AWSOptions } from './providers/aws-provider'
import { MimeType } from './mime-types.type'
import { BaseProvider } from './providers/base-provider'
Expand Down Expand Up @@ -57,6 +58,11 @@ export type UploadOptions = {
*/
filename?: string;
},
/**
* Function which defines where the file should be placed inside the bucket.
* Default to `${record.id()}/${filename}`.
*/
uploadPath?: UploadPathFunction;
/** Validation rules */
validation?: {
/**
Expand All @@ -70,6 +76,27 @@ export type UploadOptions = {
},
}

/**
* Function which defines where in the bucket file should be stored.
* If we have 2 uploads in one resource we might need to set them to
* - `${record.id()}/upload1/${filename}`
* - `${record.id()}/upload2/${filename}`
*
* By default system uploads files to: `${record.id()}/${filename}`
*
* @memberof module:@admin-bro/upload
*/
export type UploadPathFunction = (
/**
* Record for which file is uploaded
*/
record: BaseRecord,
/**
* filename with extension
*/
filename: string,
) => string

export type ProviderOptions = Required<Exclude<UploadOptions['provider'], BaseProvider>>

export type AvailableDefaultProviders = keyof ProviderOptions | 'base'
Expand Down
26 changes: 26 additions & 0 deletions src/features/upload-file/validate-properties.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from 'chai'
import { ERROR_MESSAGES } from './constants'
import { validateProperties } from './validate-properties'

describe('validateProperties', () => {
it('does not throw an all properties have different values', () => {
expect(() => {
validateProperties({
key: 'sameValue',
file: 'otherValue',
})
}).not.to.throw()
})

it('throws an error when 2 properties have the same values', () => {
expect(() => {
validateProperties({
key: 'sameValue',
file: 'sameValue',
})
}).to.throw(ERROR_MESSAGES.DUPLICATED_KEYS([{
keys: ['key', 'file'],
value: 'sameValue',
}]))
})
})
28 changes: 28 additions & 0 deletions src/features/upload-file/validate-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { DuplicateOccurrence, ERROR_MESSAGES } from './constants'
import { UploadOptions } from './upload-options.type'

/**
* Checks if values for properties given by the user are different
*
* @private
*/
export const validateProperties = (properties: UploadOptions['properties']): void => {
// counting how many occurrences of given value are in the keys.
const mappedFields = Object.keys(properties).reduce((memo, key) => {
const property = properties[key] ? {
[properties[key]]: {
keys: memo[properties[key]] ? [...memo[properties[key]].keys, key] : [key],
value: properties[key],
} } : {}
return {
...memo,
...property,
}
}, {} as Record<string, DuplicateOccurrence>)

const duplicated = Object.values(mappedFields).filter((value) => value.keys.length > 1)

if (duplicated.length) {
throw new Error(ERROR_MESSAGES.DUPLICATED_KEYS(duplicated))
}
}
48 changes: 42 additions & 6 deletions src/index.doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ There are 2 things you have to do before using this Provider.

#### 1. create the*folder** (`bucket`) for the files (i.e. `public`)

```
```sh
cd your-app
mkdir public
```
Expand All @@ -133,7 +133,7 @@ app.use('/uploads', express.static('uploads'));

Next you have to add @admin-bro/upload to given resource:

```
```javascript
* const options = {
resources: [{
resource: User,
Expand Down Expand Up @@ -170,15 +170,51 @@ const options = {
}
```

## Storing data
## Options

This feature requires just one field in the database to store the
path (S3 key) of the uploaded file.
path (Bucket key) of the uploaded file.

But it also can store more data like `bucket`, 'mimeType', 'size' etc. Fields mapping can be done
in `options.properties` like this:

```javascript
uploadFeature({
provider: {},
properties: {
// virtual properties, created by this plugin on the go. They are not stored in the database
// this is where frontend will send info to the backend
file: `uploadedFile.file`,
// here is where backend will send path to the file to the frontend [virtual property]
filePath: `uploadedFile.file`,

// DB properties: have to be in your schema
// where bucket key will be stored
key: `uploadedFile.key`,
// where mime type will be stored
mimeType: `uploadedFile.mime`,
// where bucket name will be stored
bucket: `uploadedFile.bucket`,
// where size will be stored
size: `uploadedFile.size`,
},
})
```

In the example above we nest all the properties under `uploadedFile`, `mixed` property.
This convention is a convenient way of storing multiple files in one record.

But it also can store more data like `bucket`, 'mimeType', 'size' etc.
For the list of all available properties take a look at
For the list of all options take a look at
{@link module:@admin-bro/upload.UploadOptions UploadOptions}

## Storing multiple files in one model

Since you can pass an array of features to AdminBro it allows you to define uploads multiple times for
one model. In order to make it work you have to:

* make sure to map at least `file` and `filePath` properties to different values in each upload.
* define {@link UploadPathFunction} for each upload so that files does not override each other.

## Validation

The feature can validate both:
Expand Down
Loading

0 comments on commit 29572e5

Please sign in to comment.