Skip to content

Commit a93cf5e

Browse files
feat(model): add support to upload files (#143)
Add support to upload files by checking if data has instance of `File`. If so, it set the `Content-Type` to `multipart/form-data` and convert the data object to `FormData` using `object-to-formdata`. Based on comment by @alvaro-canepa at #83
1 parent e46d63e commit a93cf5e

File tree

6 files changed

+252
-1
lines changed

6 files changed

+252
-1
lines changed

docs/content/en/api/crud-operations.md

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ category: API
1010

1111
Save or update a model in the database, then return the instance.
1212

13+
<alert type="info">When uploading files, the `Content-Type` will be set to `multipart/form-data`.</alert>
14+
1315
### create
1416

1517
<code-group>
@@ -96,6 +98,7 @@ Alias for:
9698
model.config({ method: 'PATCH' }).save()
9799
```
98100

101+
<alert type="info">When uploading files, the `Content-Type` will be set to `multipart/form-data`.</alert>
99102

100103
## `delete`
101104

docs/content/en/performing-operations.md

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ We can create a new **Post**:
6161
</code-block>
6262
</code-group>
6363

64+
<alert type="info">When uploading files, the `Content-Type` will be set to `multipart/form-data`.</alert>
65+
6466
Then we can update our newly created **Post**:
6567

6668
<code-group>

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
},
6363
"dependencies": {
6464
"dotprop": "^1.2.0",
65-
"dset": "^2.0.1"
65+
"dset": "^2.0.1",
66+
"object-to-formdata": "^4.1.0"
6667
}
6768
}

src/Model.js

+51
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { serialize } from 'object-to-formdata'
12
import getProp from 'dotprop'
23
import setProp from 'dset'
34
import Builder from './Builder'
@@ -41,6 +42,32 @@ export default class Model extends StaticModel {
4142
return this
4243
}
4344

45+
formData(options = {}) {
46+
const defaultOptions = {
47+
/**
48+
* Include array indices in FormData keys
49+
*/
50+
indices: false,
51+
52+
/**
53+
* Treat null values like undefined values and ignore them
54+
*/
55+
nullsAsUndefineds: false,
56+
57+
/**
58+
* Convert true or false to 1 or 0 respectively
59+
*/
60+
booleansAsIntegers: false,
61+
62+
/**
63+
* Store arrays even if they're empty
64+
*/
65+
allowEmptyArrays: false,
66+
}
67+
68+
return { ...defaultOptions, ...options }
69+
}
70+
4471
resource() {
4572
return `${this.constructor.name.toLowerCase()}s`
4673
}
@@ -285,6 +312,7 @@ export default class Model extends StaticModel {
285312
_reqConfig(config, options = { forceMethod: false }) {
286313
const _config = { ...config, ...this._config }
287314

315+
// Prevent default request method from being overridden
288316
if (options.forceMethod) {
289317
_config.method = config.method
290318
}
@@ -296,6 +324,29 @@ export default class Model extends StaticModel {
296324
Object.entries(_config.data)
297325
.filter(([key]) => !key.startsWith('_'))
298326
)
327+
328+
const _hasFiles = Object.keys(_config.data).some(property => {
329+
if (Array.isArray(_config.data[property])) {
330+
return _config.data[property].some(value => value instanceof File)
331+
}
332+
333+
return _config.data[property] instanceof File
334+
})
335+
336+
// Check if the data has files
337+
if (_hasFiles) {
338+
// Check if `config` has `headers` property
339+
if (!('headers' in _config)) {
340+
// If not, then set an empty object
341+
_config.headers = {}
342+
}
343+
344+
// Set header Content-Type
345+
_config.headers['Content-Type'] = 'multipart/form-data'
346+
347+
// Convert object to form data
348+
_config.data = serialize(_config.data, this.formData())
349+
}
299350
}
300351

301352
return _config

tests/model.test.js

+189
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,195 @@ describe('Model methods', () => {
446446
})
447447
})
448448

449+
test('save() method makes a POST request when ID of object does not exists, with header "Content-Type: multipart/form-data" if the data has files', async () => {
450+
let post
451+
const file = new File(["foo"], "foo.txt", {
452+
type: "text/plain",
453+
})
454+
const _postResponse = {
455+
id: 1,
456+
title: 'Cool!'
457+
}
458+
459+
axiosMock.onAny().reply((config) => {
460+
let _data
461+
462+
if (config.headers['Content-Type'] === 'multipart/form-data') {
463+
_data = Object.fromEntries(config.data)
464+
465+
if (_data['files[]']) {
466+
_data.files = [{}, {}]
467+
delete _data['files[]']
468+
}
469+
470+
_data = JSON.stringify(_data)
471+
} else {
472+
_data = config.data
473+
}
474+
475+
expect(config.method).toEqual('post')
476+
expect(config.headers['Content-Type']).toEqual('multipart/form-data')
477+
expect(_data).toEqual(JSON.stringify(post))
478+
expect(config.url).toEqual('http://localhost/posts')
479+
480+
return [200, _postResponse]
481+
})
482+
483+
// Single files
484+
post = new Post({ title: 'Cool!', file })
485+
await post.save()
486+
487+
// Multiple files
488+
post = new Post({ title: 'Cool!', files: [file, file] })
489+
await post.save()
490+
})
491+
492+
test('save() method makes a PUT request when ID of when ID of object exists, with header "Content-Type: multipart/form-data" if the data has files', async () => {
493+
let post
494+
const file = new File(["foo"], "foo.txt", {
495+
type: "text/plain",
496+
})
497+
const _postResponse = {
498+
id: 1,
499+
title: 'Cool!'
500+
}
501+
502+
axiosMock.onAny().reply((config) => {
503+
let _data
504+
505+
if (config.headers['Content-Type'] === 'multipart/form-data') {
506+
_data = Object.fromEntries(config.data)
507+
_data.id = 1
508+
509+
if (_data['files[]']) {
510+
_data.files = [{}, {}]
511+
delete _data['files[]']
512+
}
513+
514+
_data = JSON.stringify(_data)
515+
} else {
516+
_data = config.data
517+
}
518+
519+
expect(config.method).toEqual('put')
520+
expect(config.headers['Content-Type']).toEqual('multipart/form-data')
521+
expect(_data).toEqual(JSON.stringify(post))
522+
expect(config.url).toEqual('http://localhost/posts/1')
523+
524+
return [200, _postResponse]
525+
})
526+
527+
// Single file
528+
post = new Post({ id: 1, title: 'Cool!', file })
529+
await post.save()
530+
531+
// Multiple files
532+
post = new Post({ id: 1, title: 'Cool!', files: [file, file] })
533+
await post.save()
534+
})
535+
536+
test('patch() method makes a PATCH request when ID of when ID of object exists, with header "Content-Type: multipart/form-data" if the data has files', async () => {
537+
let post
538+
const file = new File(["foo"], "foo.txt", {
539+
type: "text/plain",
540+
})
541+
const _postResponse = {
542+
id: 1,
543+
title: 'Cool!'
544+
}
545+
546+
axiosMock.onAny().reply((config) => {
547+
let _data
548+
const _post = post
549+
delete _post._config
550+
551+
if (config.headers['Content-Type'] === 'multipart/form-data') {
552+
_data = Object.fromEntries(config.data)
553+
_data.id = 1
554+
555+
if (_data['files[]']) {
556+
_data.files = [{}, {}]
557+
delete _data['files[]']
558+
}
559+
560+
_data = JSON.stringify(_data)
561+
} else {
562+
_data = config.data
563+
}
564+
565+
expect(config.method).toEqual('patch')
566+
expect(config.headers['Content-Type']).toEqual('multipart/form-data')
567+
expect(_data).toEqual(JSON.stringify(_post))
568+
expect(config.url).toEqual('http://localhost/posts/1')
569+
570+
return [200, _postResponse]
571+
})
572+
573+
// Single file
574+
post = new Post({ id: 1, title: 'Cool!', file })
575+
await post.patch()
576+
577+
// Multiple files
578+
post = new Post({ id: 1, title: 'Cool!', files: [file, file] })
579+
await post.patch()
580+
})
581+
582+
test('save() method can add header "Content-Type: multipart/form-data" when "headers" object is already defined', async () => {
583+
let post
584+
const file = new File(["foo"], "foo.txt", {
585+
type: "text/plain",
586+
})
587+
const _postResponse = {
588+
id: 1,
589+
title: 'Cool!',
590+
text: 'Lorem Ipsum Dolor',
591+
user: {
592+
firstname: 'John',
593+
lastname: 'Doe',
594+
age: 25
595+
},
596+
relationships: {
597+
tags: [
598+
{
599+
name: 'super'
600+
},
601+
{
602+
name: 'awesome'
603+
}
604+
]
605+
}
606+
}
607+
608+
axiosMock.onAny().reply((config) => {
609+
let _data
610+
const _post = post
611+
delete _post._config
612+
613+
if (config.headers['Content-Type'] === 'multipart/form-data') {
614+
_data = JSON.stringify(Object.fromEntries(config.data))
615+
} else {
616+
_data = config.data
617+
}
618+
619+
expect(config.method).toEqual('post')
620+
expect(config.headers['Content-Type']).toStrictEqual('multipart/form-data')
621+
expect(_data).toEqual(JSON.stringify(_post))
622+
expect(config.url).toEqual('http://localhost/posts')
623+
624+
return [200, _postResponse]
625+
})
626+
627+
post = new Post({ title: 'Cool!', file })
628+
post = await post.config({ headers: {} }).save()
629+
630+
expect(post).toEqual(_postResponse)
631+
expect(post).toBeInstanceOf(Post)
632+
expect(post.user).toBeInstanceOf(User)
633+
post.relationships.tags.forEach(tag => {
634+
expect(tag).toBeInstanceOf(Tag)
635+
})
636+
})
637+
449638
test('save() method makes a POST request when ID of object is null', async () => {
450639
let post
451640

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -5729,6 +5729,11 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
57295729
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
57305730
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
57315731

5732+
object-to-formdata@^4.1.0:
5733+
version "4.1.0"
5734+
resolved "https://registry.yarnpkg.com/object-to-formdata/-/object-to-formdata-4.1.0.tgz#0d7bdd6f9e4efa8c0075a770c11ec92b7bf6c560"
5735+
integrity sha512-4Ti3VLTspWOUt5QIBl5/BpvLBnr4tbFpZ/FpXKWQFqLslvGIB3ug3jurW/k8iIpoyE6HcZhhmQ6mFiOq1tKGEQ==
5736+
57325737
object-visit@^1.0.0:
57335738
version "1.0.1"
57345739
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"

0 commit comments

Comments
 (0)