0

I haven't been able to figure out how to get my JavaScript to send a request in a format that Rails will accept when I try to edit a Game with a File parameter and an array parameter in the same payload.

The Rails controller looks like this (simplified, obviously):

class GamesController < ApplicationController
  def update
    @game = Game.find(params[:id])
    authorize @game

    respond_to do |format|
      if @game.update(game_params)
        format.html { render html: @game, success: "#{@game.name} was successfully updated." }
        format.json { render json: @game, status: :success, location: @game }
      else
        format.html do
          flash.now[:error] = "Unable to update game."
          render :edit
        end
        format.json { render json: @game.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  def game_params
    params.require(:game).permit(
      :name,
      :cover,
      genre_ids: [],
      engine_ids: []
    )
  end
end

So I have JavaScript like so:

// this.game.genres and this.game.engines come from
// elsewhere, they're both arrays of objects. These two
// lines turn them into an array of integers representing
// their IDs.
let genre_ids = Array.from(this.game.genres, genre => genre.id);
let engine_ids = Array.from(this.game.engines, engine => engine.id);

let submittableData = new FormData();
submittableData.append('game[name]', this.game.name);
submittableData.append('game[genre_ids]', genre_ids);
submittableData.append('game[engine_ids]', engine_ids);
if (this.game.cover) {
  // this.game.cover is a File object
  submittableData.append('game[cover]', this.game.cover, this.game.cover.name);
}

fetch("/games/4", {
  method: 'PUT',
  body: submittableData,
  headers: {
    'X-CSRF-Token': Rails.csrfToken()
  },
  credentials: 'same-origin'
}).then(
  // success/error handling here
)

The JavaScript runs when I hit the submit button in a form, and is supposed to convert the data into a format Rails' backend will accept. Unfortunately, I'm having trouble getting it to work.

I'm able to use JSON.stringify() instead of FormData for submitting the data in the case where there's no image file to submit, like so:

fetch("/games/4", {
  method: 'PUT',
  body: JSON.stringify({ game: {
    name: this.game.name,
    genre_ids: genre_ids,
    engine_ids: engine_ids
  }}),
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': Rails.csrfToken()
  },
  credentials: 'same-origin'
})

This works fine. But I haven't been able to figure out how to use JSON.stringify when submitting a File object. Alternatively, I can use a FormData object, which works for simple values, e.g. name, as well as File objects, but not for array values like an array of IDs.

A successful form submit with just the ID arrays (using JSON.stringify) looks like this in the Rails console:

Parameters: {"game"=>{"name"=>"Pokémon Ruby", "engine_ids"=>[], "genre_ids"=>[13]}, "id"=>"4"}

However, my current code ends up with something more like this:

Parameters: {"game"=>{"name"=>"Pokémon Ruby", "genre_ids"=>"18,2,15", "engine_ids"=>"4,2,10"}, "id"=>"4"}

Unpermitted parameters: :genre_ids, :engine_ids

Or, if you also upload a file in the process:

Parameters: {"game"=>{"name"=>"Pokémon Ruby", "genre_ids"=>"13,3", "engine_ids"=>"5", "cover"=>#<ActionDispatch::Http::UploadedFile:0x00007f9a45d11f78 @tempfile=#<Tempfile:/var/folders/2n/6l8d3x457wq9m5fpry0dltb40000gn/T/RackMultipart20190217-31684-1qmtpx2.png>, @original_filename="Screen Shot 2019-01-27 at 5.26.23 PM.png", @content_type="image/png", @headers="Content-Disposition: form-data; name=\"game[cover]\"; filename=\"Screen Shot 2019-01-27 at 5.26.23 PM.png\"\r\nContent-Type: image/png\r\n">}, "id"=>"4"}

Unpermitted parameters: :genre_ids, :engine_ids

TL;DR: My question is, how can I send this payload (a name string, an array of IDs, as well as a game cover image) to Rails using JavaScript? What format will actually be accepted and how do I make that happen?


The Rails app is open source if that'd help at all, you can see the repo here. The specific files mentioned are app/controllers/games_controller.rb and app/javascript/src/components/game-form.vue, though I've simplified both significantly for this question.

Connor Shea
  • 795
  • 7
  • 22
  • I should note that I did figure out that if you do something like this: `submittableData.append('game[engine_ids][]', engine_ids);` It'll work, but the Rails backend recieves something that looks like `"engine_ids"=>["5,7"]` – Connor Shea Feb 18 '19 at 02:09

2 Answers2

1

I figured out that I can do this using ActiveStorage's Direct Upload feature.

In my JavaScript:

// Import DirectUpload from ActiveStorage somewhere above here.
onChange(file) {
  this.uploadFile(file);
},
uploadFile(file) {
  const url = "/rails/active_storage/direct_uploads";
  const upload = new DirectUpload(file, url);

  upload.create((error, blob) => {
    if (error) {
      // TODO: Handle this error.
      console.log(error);
    } else {
      this.game.coverBlob = blob.signed_id;
    }
  })
},
onSubmit() {
  let genre_ids = Array.from(this.game.genres, genre => genre.id);
  let engine_ids = Array.from(this.game.engines, engine => engine.id);
  let submittableData = { game: {
    name: this.game.name,
    genre_ids: genre_ids,
    engine_ids: engine_ids
  }};

  if (this.game.coverBlob) {
    submittableData['game']['cover'] = this.game.coverBlob;
  }

  fetch(this.submitPath, {
    method: this.create ? 'POST' : 'PUT',
    body: JSON.stringify(submittableData),
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': Rails.csrfToken()
    },
    credentials: 'same-origin'
  })
}

I then figured out that, with the way DirectUpload works, I can just send the coverBlob variable to the Rails application, so it'll just be a string. Super easy.

Connor Shea
  • 795
  • 7
  • 22
  • 1
    One thing you might want to do is `<%= f.file_field :whatever, direct_upload: true %>` to get Rails to put the upload URL in a data attribute and then, in your JavaScript, you can pull the URL out of the ``'s `data-direct-upload-url` attribute. Then everything should continue working if you switch to non-local storage (such as S3). You can also hook all this up to [a ``](https://stackoverflow.com/a/54701724/479863) if you want to do some simple image editing in the browser without uploading any intermediate files. – mu is too short Feb 18 '19 at 04:42
  • 1
    @muistooshort I don't actually use a
    element for this, it's all a VueJS component. Is there any way you know of to get the direct upload route without using the file_field helper? Would the `rails_direct_uploads` route work? EDIT: Yup, it does! I passed `<%= rails_direct_uploads_path.to_json %>` into the Vue component and it works without hardcoding the string in the JavaScript :D
    – Connor Shea Feb 18 '19 at 04:57
  • 1
    Or in general, [`rails_direct_uploads_url`](https://github.com/rails/rails/blob/v5.2.2/actionview/lib/action_view/helpers/form_tag_helper.rb#L909). That's what the Rails form helper uses. – mu is too short Feb 18 '19 at 06:29
0

You can convert the File object to a data URL and include that string in JSON, see processFiles function at Upload multiple image using AJAX, PHP and jQuery or use JSON.stringify() on the JavaScript Array and set that as value of FormData object, instead of passing the Array as value to FormData.

submittableData.append('game[name]', JSON.stringify(this.game.name));
submittableData.append('game[genre_ids]', JSON.stringify(genre_ids));
submittableData.append('game[engine_ids]', JSON.stringify(engine_ids));
guest271314
  • 1
  • 15
  • 104
  • 177
  • If I change the JavaScript to use `JSON.stringify`, the engine/genres don't get sent in a format Rails will accept, so the game genres and engines aren't updated. The response the Rails app gets is this: `Parameters: {"game"=>{"name"=>"\"Pokémon Ruby\"", "genre_ids"=>"[13,3]", "engine_ids"=>"[5]"}, "id"=>"4"}` – Connor Shea Feb 18 '19 at 02:17
  • Yes, that is valid escaped `JSON`, correct? Does Rails have methods to parse valid `JSON`? The issue appears to be that JavaScript `Array`s are passed to `FormData`, not `JSON`. Is the only issue converting a `File` object to a string? – guest271314 Feb 18 '19 at 02:18
  • Yes, but as I said the problem is that I can't get the image files to send correctly when uploading them as JSON, which is why I used FormData. So, I guess a valid solution would be to find a way for Rails to accept the image file when submitted as JSON, so FormData wouldn't be necessary. – Connor Shea Feb 18 '19 at 02:22
  • @ConnorShea As described in the answer you can convert the `File` object to a `data URL` then parse the `MIME` type and `base64` portions of the file at the server. Or send the `MIME` type and the `base64` portion of the `data URL` separately in the `FormData` object, at server get and store the `base64` as a file with extension parsed from the `MIME` type. Consider `"data:text/plain,YWJj` `POST`ed as `"text/plain"` => `.txt`, `"YWJj"` => `"abc"` => `uploadedFile.txt`. – guest271314 Feb 18 '19 at 02:29
  • Ideally, I'd like to have ActiveStorage handle the file for me so I don't need to add any special code to have it parse the base64 data URL that my JavaScript submits. The solution (for my use case) should maybe use the DirectUpload functionality described here: https://edgeguides.rubyonrails.org/active_storage_overview.html#direct-uploads – Connor Shea Feb 18 '19 at 02:41
  • @ConnorShea Have not tried Rails. In PHP the data can be parsed using either procedure described in the answer. Not sure why you are having issues parsing the `JSON` set as values at a `FormData` object. – guest271314 Feb 18 '19 at 02:45
  • 1
    @ConnorShea: If you use [direct uploads with Active Storage](https://guides.rubyonrails.org/active_storage_overview.html#direct-uploads) then file part goes away as you'd just by sending a signed ID string to the controller. You need to do a little more work to deal with error handling and clean up though. – mu is too short Feb 18 '19 at 04:04
  • 1
    @muistooshort thanks, the signed ID string hint helped a lot! I posted my solution :) – Connor Shea Feb 18 '19 at 04:34