How to upload a file on the frontend and send it using JS to a Rails backend
I was recently working on a take home assessment where I had to let the user upload a file and then have the backend parse and manipulate the file. Doesn’t sound too bad, but when I sat down to do it I realized that I had never worked with uploading a file.
Cue a Google search where I learned that along with the input types I am used to like text
, submit
, password
, etc., there is also an input type of file
in an HTML form. For anyone curious, there are actually a ton of input types that I had never seen before that can be found here.
The input type of file
allows user to choose one or more files from their computer. There are 4 additional attributes available when the type of file
is used:
accept
: describes file types to allow as denoted by unique file type specifierscapture
: source to use if capturing image or video datafiles
: the list of selected filesmultiple
: boolean, if true allows the user to select more than one file
For my assessment, I needed to let the user upload a single json
file. So I created an HTML form with 2 inputs, one for the file and one for the submit button. Let’s break down the file input:
type="file"
: allows the user to choose one or more files from their computername="uploadFile"
: names the input field so I can later target it via JSaccept=".json"
: only allowsjson
files to be selectedrequired
: does not allow the user to submit the form without having a value in this input field
<form class="upload">
<input type="file" name="uploadFile" accept=".json" required />
<br/><br/>
<input type="submit" />
</form>
Upon click of “Choose File”, I am prompted to select a file from my computer. On my desktop I currently have 2 screenshots and one test_data.json
file. Since I specified that my input should only accept json
files, I am not able to select either of screenshots (they are greyed out).
Then using JavaScript, I added a submit event listener onto the form. Like any form, I can grab the input from the name
attribute I gave it in the HTML, which in our case is e.target.uploadFile
. Usually I would use e.target.uploadFile.value
to get the value of the input, but for a file I actually want e.target.uploadFile.files
, which is an array of the uploaded files. In this case only one file was uploaded so I can just grab the one at the zero index.
const uploadForm = document.querySelector('.upload')uploadForm.addEventListener('submit', function(e) {
e.preventDefault()
let file = e.target.uploadFile.files[0]
})
Now that I have the uploaded file, I want to send it to my Rails backend via a POST
request, but this is where things get interesting. Normally a POST
request looks something like this where I’m sending a JSON
object as the body
and including headers
:
fetch('http://localhost:3000/users', {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
name: name,
})
})
.then(resp => resp.json())
.then(data => {
// do something here
})
But in this case, I don’t want to send a JSON
object with values, I want to send the contents of the uploaded file. I’ll save you the frustration of trying lots of combinations of things that do not work. The solutions involves using FormData
.
FormData
is an object that lets you set key/value pairs to be sent in a request. It is usually used to send form data, hence the name. To utilize FormData
you have to create a new instance of it, and then can use append
to add key/value pairs. Here I am adding a key of file
and setting it to the file
variable that contains our uploaded file:
const uploadForm = document.querySelector('.upload')uploadForm.addEventListener('submit', function(e) {
e.preventDefault()
let file = e.target.uploadFile.files[0] let formData = new FormData()
formData.append('file', file)
})
Now I can finish my POST
request and pass formData
as the body
. I also have my rails server set up locally on port 3000 and my route is /upload_files
. Since we are using FormData
, the content type is actually 'multipart/form-data'
. However, it is important to not include headers: { "Content-Type": 'multipart/form-data' }
in the POST
request. The browser will set the content type for us, including another boundary
parameter, and including the content type will actually cause major problems. A more detailed explanation can be found here.
const uploadForm = document.querySelector('.upload')uploadForm.addEventListener('submit', function(e) {
e.preventDefault()
let file = e.target.uploadFile.files[0] let formData = new FormData()
formData.append('file', file) fetch('http://localhost:3000/upload_files', {
method: 'POST',
body: formData
})
.then(resp => resp.json())
.then(data => {
if (data.errors) {
alert(data.errors)
}
else {
console.log(data)
}
})
})
Finally, to the Rails backend. In my UploadFiles
controller, I have a create
action where I ultimately want to read and parse the file, and do anything else I need with it. If I stick a byebug
in that method, I can see what my params
look like:
<ActionController::Parameters {"file"=>#<ActionDispatch::Http::UploadedFile:0x00007fc53f1be638 @tempfile=#<Tempfile:/var/folders/sl/nh9hb7j102q9xcwsy3jrtskw0000gn/T/RackMultipart20200206-63800-1mm9z70.json>, @original_filename="test_data.json", @content_type="application/json", @headers="Content-Disposition: form-data; name=\"file\"; filename=\"test_data.json\"\r\nContent-Type: application/json\r\n">, "controller"=>"upload_files", "action"=>"create"} permitted: false>
See that file
within params
? It should look familiar since I added a key of file
to my FormData
.
So now I can look at params[:file]
:
#<ActionDispatch::Http::UploadedFile:0x00007fc53f1be638 @tempfile=#<Tempfile:/var/folders/sl/nh9hb7j102q9xcwsy3jrtskw0000gn/T/RackMultipart20200206-63800-1mm9z70.json>, @original_filename="test_data.json", @content_type="application/json", @headers="Content-Disposition: form-data; name=\"file\"; filename=\"test_data.json\"\r\nContent-Type: application/json\r\n">
What I care about is the tempfile
attribute which is a temporary copy of my file. I can call the read
method on the temporary file, params[:file].tempfile
, to get the text within the file. Since my file is JSON
, I can then parse the contents of the file as JSON
via JSON.parse
:
def create
begin
file = params[:file].tempfile.read
data = JSON.parse(file)
render json: data
rescue
render json: { errors: 'Upload failed' }
end
end
I’m using a begin/rescue
here so that if I run into any errors when reading or parsing, I can send back a generic “Upload failed” error message. I’m also just sending back the read and parsed data (which is ultimately just getting logged to the console), but you can really do anything with this data once you have it parsed.
Hope this was helpful!
Sources
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers