I can't get the format option on form_with to do anything in Rails 7 -- is this a bug?

The only way I can get my Rails controller to believe that the request has a JSON format is to use a path with .json as a suffix. The format: :json option has no effect. I’ve tried many variations and the result is always a format of either HTML TURBO_STREAMS.

I did discover that the path helpers take the format option, e.g. users_path(format: :json) but I don’t see it documented anywhere.

Can you provide your full form_with line?

I put a rather long explanation of my experimentation here. I no longer have the various examples that I tried.

Let’s go by steps.

If form_with url: '/registration.json' worked, that means that Turbo is respecting the request for a JSON format. This means that as long as the action attribute of the generated HTML ends in .json you will get what you want.

Second, by looking into form_for code we can see that it simply makes a few adjustments to the options and passes it to form_with. Looking at the form_with code we can see this:

if model
  if url != false
    url ||= if format.nil?
      polymorphic_path(model, {})
    else
      polymorphic_path(model, format: format)
    end
  end

  model   = convert_to_model(_object_for_form_builder(model))
  scope ||= model_name_from_record_or_class(model).param_key
end

This basically means that format will only work if paired with the model option. Now, in your Reddit post I see you tested it. But could you try it again and check the generated HTML to see if the action attribute of the form ends in .json?

From there we can try to figure out where the error is.

Thank you for your help. This gets confusing.

Today, when I started, I thought I had torn down my test rig and so I started testing on a different controller / view.

For form_with with a model and no format specified:

<%= form_with model: import_file do |form| %>

produces

<form enctype="multipart/form-data" action="/import_files" accept-charset="UTF-8" method="post">

And as you said, a form_with with a model and a format:

<%= form_with model: import_file, format: :json do |form| %>

produces

<form enctype="multipart/form-data" action="/import_files.json" accept-charset="UTF-8" method="post">

Hitting the submit button sends a JSON request. So, your conclusions are correct.

I then discovered that I did still have my test rig so I went back to it. This controller / model / view is called “registration”. Here are the pertinent parts:

The controller’s new method:

  def new
    @registration = Registration.new
  end

The model:

class Registration
  include ActiveModel::API

  attr_accessor :username, :nickname
end

The form_with:

    <%= form_with model: @registration, remote: true, format: :json, data: { turbo: false } do |form| %>

The routes:

  resource :registration, only: %i[new create] do
    post :callback
  end
  resolve('Registration') { [:registration] }

As the Reddit post explains, on the page I have 8 of these forms with the remote, format, and turbo options toggling between all 8 combinations. In all of these forms, the path is just /registration and the .json suffix is missing. Here is the first example:

<form data-turbo="false" action="/registration" accept-charset="UTF-8" method="post">

Had I experimented with the import_file model / view / controller before, I would have noticed that the format worked there but not on the registration trio. My guess right now is somehow the model is the difference.

And, indeed, if I hack the registration#new method to be:

  def new
    @registration = ImportFile.new
  end

now the forms have registration.json as their path.

So, the net net net is that somehow my Registration active model is causing this.

Could you try removing the resolve route and see if it helps? I think it might be taking precedence over other route generation methods and causing the format to be ignored.

I hope you realize by now that I am somewhat clueless about a lot of this.

When I remove the resolve:

  resource :registration, only: %i[new create] do
    post :callback
  end
  # resolve('Registration') { [:registration] }

and restart the server, I get:

undefined method `registrations_path' for #<ActionView::Base:0x00000000014b68>

I recall now that I got this error before. Not knowing what I’m actually doing, I went to the Rails guide about Singular Resources and blindly followed what it said.

Also, to clarify, I’m trying to get a Rails 7 app to use passkeys and I’m using webauthn-rails-demo-app as my guide (which is a Rails 6.0.6 app). Thus the current names I just followed. At this point, I’ve given up on this particular approach and am starting a fresh Rails 7 app and I’m going to cherry pick the parts I need and redo the rest of it using more Stimulus and Turbo and avoiding rails-ujs.

Update 2: if I change the route to:

  resource :registrations, only: %i[new create] do
    post :callback
  end

and the URL to /registrations/new, it works:

<form data-turbo="false" action="/registrations.json" accept-charset="UTF-8" method="post">

No problem. Rails has two quick ways to create paths: resource (singular) and resources (plural). For example, if your app has a lot of users, so it should use the plural version:

resources :users # notice both are plural

And if you check which routes are created you will see this (notice how both #index and #create use the prefix users in the plural):

# rails routes -g user

   Prefix Verb   URI Pattern               Controller#Action
    users GET    /users(.:format)          users#index
          POST   /users(.:format)          users#create
 new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
     user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

Now, if your app had only a single user you would use the singular version:

resource :user # notice both are singular

And if you check which routes are created you will see this (notice how both #index no longer exists and #create uses the prefix user in the singular):

# rails routes -g user

   Prefix Verb   URI Pattern          Controller#Action
 new_user GET    /user/new(.:format)  users#new
edit_user GET    /user/edit(.:format) users#edit
     user GET    /user(.:format)      users#show
          PATCH  /user(.:format)      users#update
          PUT    /user(.:format)      users#update
          DELETE /user(.:format)      users#destroy
          POST   /user(.:format)      users#create

So going back to your use case, let’s test your routes:

resource :registration, only: %i[new create] do
  post :callback
end
  
# rails routes -g registration  

               Prefix Verb URI Pattern                      Controller#Action
callback_registration POST /registration/callback(.:format) registrations#callback
     new_registration GET  /registration/new(.:format)      registrations#new
         registration POST /registration(.:format)          registrations#create

As you can see for the third line, first column the helpers for POST are registration in the SINGULAR because you used resource in the singular. This means that in your form, if you decide o use the url: param, you need to use registration_path, not registrations_path.

Try not to mix and match forms:

# Wrong
resource :users
resources :user

# Correct
resources :users
resource :user

This will cause rails to generate some weird helpers like user_index_path instead of users_path

But it appears if I use model: @foo then something somewhere assumes the route is plural unless I add in the resolve. And I must use model: to get format: to work. Is all that correct?

The “something” that assumes the routes is the helper polymorphic_url and polymorphic_path, which you can see being used in the block of code I’ve pasted in my first post.

You can test it by doing opening the rails console with rails c and running these lines:

include Rails.application.routes.url_helpers
default_url_options[:host] = "http://localhost:3000"

user = User.find(1)
polymorphic_url user

# http://localhost:3000/users/1

When you give a form a model but not an url it will call polymorphic_path on your model, which will then go into your routes and try to find the best match for that model. Usually that will be a resource or resources route named user or users. I am frankly not very familiar with resolve since I never used it and don’t really know how it affects things.

Yes, for format to work you must use model and not use url (since it takes precedence over the automatic path generation by polymorphic_url.

Frankly, all this can be simplified by always using the url option in your forms. It makes things more explicit and less confusing. At least, that’s what we do where I work.

I didn’t realize originally that foo_path can take a format option so I can say foo_path(format: :json). Thus the take away is:

  1. if you are using model: @foo, then format: :json on the form_for or form_with applies.
  2. if you are using url: foo_path then add the format option to the path method: url: foo_path(format: :json).

I agree that using url and scope removes much of the magic and make things clearer.

Thank you again for all your help

2 Likes

It’s fine using model and url too, since the path generated by the url attribute takes precedence over the one by model.