Structuring Multipart FormData with Rails Naming Conventions

Bryan Haney
4 min readSep 10, 2020

As part of my studies at https://flatironschool.com/, I encountered several caveats when attempting to utilize multipart FormData with a Rails API backend. My use case involved images with accompanying descriptions and tags.

Strong Params

When using strong params in Rails, we permit.require(:controller_name), with :controller_name being the singular name of the controller that we are in.

When POSTing to a Rails API endpoint, a hash of :controller_name is automagically added to the root of the request by Rails’ router.

For example, consider the following route in routes.rb, as well as a corresponding fetch:

post '/users', to: 'users#create'

If we log params from within the users#create action, we find the body of our fetch automagically nested inside of a :user hash:

# In UsersController
def create
puts params
end

# Server log, upon receiving the fetch and routing to user#create:
=> {"name"=>"Bryan", "controller"=>"users", "action"=>"create", "user"=>{"name"=>"Bryan""}}

To utilize strong params and mass assignment, we require that :user hash and permit its desired attributes.

def create
user = User.create(user_params)
render json: user
end
privatedef user_params
params.require(:user).permit(:name)
end

Strong Presumptions

Having always used similarly constructed fetches, I expected similar behavior from multipart FormData.

Let’s build a FormData, POST it and see what happens:

# In UserController
def create
puts params
puts user_params
end
privatedef user_params
params.require(:user).permit(:file, :name)
end
# Server log:Error occurred while parsing request parameters.Contents:------WebKitFormBoundaryFwYm7IV8PzeE6F6EContent-Disposition: form-data; name="file"; filename="CookieMonster.jpg"Content-Type: image/jpeg????JFIF??Compressed by jpeg-recompress???# Followed by pages of scrambled text...

As it turns out, we aren’t transmitting JSON — we need to remove the header from our fetch request. Let’s try again.

# Server log:=> <ActionController::Parameters {"file"=>#<ActionDispatch::Http::UploadedFile:0x00007fdca8487c40 @tempfile=#<Tempfile:/var/folders/hk/sgkbgpq57_37rq3l1gfjsb940000gn/T/RackMultipart20200909-42995-13a9put.jpg>, @original_filename="CookieMonster.jpg", @content_type="image/jpeg", @headers="Content-Disposition: form-data; name=\"file\"; filename=\"CookieMonster.jpg\"\r\nContent-Type: image/jpeg\r\n">, "name"=>"Bryan", "controller"=>"users", "action"=>"create"} permitted: false>ActionController::ParameterMissing: param is missing or the value is empty: user

Our file has transmitted, as has our name attribute, but params.require(:user) is refusing our request. What happened to our root level :user hash? It turns out Rails will not generate the root level hash without the JSON header — we have to build it into the FormData structure ourselves. For this, we must use the Rails parameter naming conventions: https://guides.rubyonrails.org/v3.2.13/form_helpers.html#understanding-parameter-naming-conventions

A final attempt:

# Server log:#params
=> <ActionController::Parameters {"user"=>{"file"=>#<ActionDispatch::Http::UploadedFile:0x00007fe628706e60 @tempfile=#<Tempfile:/var/folders/hk/sgkbgpq57_37rq3l1gfjsb940000gn/T/RackMultipart20200909-43265-1yopi5p.jpg>, @original_filename="CookieMonster.jpg", @content_type="image/jpeg", @headers="Content-Disposition: form-data; name=\"user[file]\"; filename=\"CookieMonster.jpg\"\r\nContent-Type: image/jpeg\r\n">, "name"=>"Bryan"}, "controller"=>"users", "action"=>"create"} permitted: false>
#user_params
<ActionController::Parameters {"file"=>#<ActionDispatch::Http::UploadedFile:0x00007fe628706e60 @tempfile=#<Tempfile:/var/folders/hk/sgkbgpq57_37rq3l1gfjsb940000gn/T/RackMultipart20200909-43265-1yopi5p.jpg>, @original_filename="CookieMonster.jpg", @content_type="image/jpeg", @headers="Content-Disposition: form-data; name=\"user[file]\"; filename=\"CookieMonster.jpg\"\r\nContent-Type: image/jpeg\r\n">, "name"=>"Bryan"} permitted: true>

Success! Our file and attribute has transmitted, we have a :user hash and params.require(:user) is happy.

Arrays

FormData is able to store arrays of values. To do so, we iteratively attach an item with the same name.

Let’s update our permitted params to allow an array of tag_ids, and attempt a POST.

# In UserController
def create
puts params
puts user_params
end
privatedef user_params
params.require(:user).permit(tag_ids: [])
end
# Server log:#params
=> <ActionController::Parameters {"user"=>{"tag_ids"=>"5"}, "controller"=>"users", "action"=>"create"} permitted: false>
#user_params
<ActionController::Parameters {} permitted: true>

That didn’t work. Despite our JS console log showing the array, Rails only kept the last tag_id and made it a key/value pair. Because .permit was expecting an array, it rejected the key/value pair.

The solution, again, is to use Rails parameter naming conventions while constructing the FormData. MDN notes this technique as “being compatible with PHP naming conventions”: https://developer.mozilla.org/en-US/docs/Web/API/FormData/append

# Server log:#params
=> <ActionController::Parameters {"user"=>{"tag_ids"=>["2", "3", "5"]}, "controller"=>"user", "action"=>"create"} permitted: false>
#user_params
<ActionController::Parameters {"tag_ids"=>["2", "3", "5"]} permitted: true>

Success!

Empty Arrays with Strong Params

Mass assignment with an empty array is a useful way to delete associations. For instance, to remove all associated tags from a user:

User.first.update(tag_ids: [])

Let’s try a PATCH request with an empty tag_ids array:

# Server log:#params
=> <ActionController::Parameters {"user"=>{"tag_ids"=>["null"]}, "controller"=>"users", "action"=>"create"} permitted: false>
#user_params
<ActionController::Parameters {"tag_ids"=>["null"]} permitted: true>

That’s not quite what we wanted, but it did made it through the strong params filter. How would an ActiveRecord .update behave with [“null”]?

User.first.update(tags: ["null"])User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]](0.1ms)  BEGINTag Load (0.2ms)  SELECT "tags".* FROM "tags" WHERE "tags"."id" = $1  [["id", 0]](0.1ms)  ROLLBACKTraceback (most recent call last):1: from (irb):8ActiveRecord::RecordNotFound (Couldn't find Tag with 'id'=[0])

Rails fired a database query looking for a Tag with an id of 0 — interesting, but not at all helpful. The question remains: how can we get an empty array into our params?

Under the hood, forms are strings — the key/value pairs don’t have a concept of null. The solution is to assign a value of empty string, and let Rails do the rest:

# Server Log#params
=> <ActionController::Parameters {"user"=>{"tags"=>[""]}, "controller"=>"users", "action"=>"create"} permitted: false>
#user_params
<ActionController::Parameters {"tags"=>[""]} permitted: true>

How does ActiveRecord .update behave with [“”]? Exactly the same as [].

User.first.update(tag_ids: [""])User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]](0.1ms)  BEGINTag Load (0.2ms)  SELECT "tags".* FROM "tags" WHERE 1=0Tag Load (0.3ms)  SELECT "tags".* FROM "tags" INNER JOIN "user_tags" ON "tags"."id" = "user_tags"."tag_id" WHERE "user_tags"."user_id" = $1  [["user_id", 1]]UserTag Destroy (0.3ms)  DELETE FROM "user_tags" WHERE "user_tags"."user_id" = $1 AND "user_tags"."tag_id" IN ($2, $3, $4, $5, $6, $7, $8, $9)  [["user_id", 1], ["tag_id", 1], ["tag_id", 2], ["tag_id", 8], ["tag_id", 11], ["tag_id", 12], ["tag_id", 14], ["tag_id", 27], ["tag_id", 31]](1.1ms)  COMMIT=> true

Success!

--

--

Bryan Haney

Full stack web developer with a passion for number theory and algorithms.