ruby-on-railsrubymongoid

Wrong parsing of array of float when using HTTP verbs in Rspecs test


I have defined the following classes:

shop.rb:

class Shop
    field: :reputation, Float
    embeds_one :location, class_name: "Location"
    accepts_nested_attributes_for :location
end

location.rb:

class Location
  include Mongoid::Document

  field :address, type: String
  field :coordinates, type: Array
  field :place_id, type: String

  validate :coordinates_must_be_pair_of_float

  private

  def coordinates_must_be_pair_of_float
    unless coordinates.is_a?(Array) && coordinates.size == 2
      errors.add(:coordinates, "must be an array with exactly two elements")
      return
    end

    coordinates.each do |coord|
      unless coord.is_a?(Float)
        errors.add(:coordinates, "must contain only integers")
        return
      end
    end
  end
end

In shop_controller.rb:

  def create
    shop = Shop.new(shop_params)

    if shop.save
      render json: { message: 'Shop created successfully', shop: shop }, status: :created
    else
      render json: { errors: shop.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def shop_params
    params.require(:shop).permit(
      :reputation,
      location_attributes: [:address, :place_id, coordinates: []],
    )
  end

Finally, in shop_spect.rb:

  let(:location) { { address: "St. My Street", coordinates: [-100.0, 100.0], place_id: "12345" } }

  describe "POST /shop" do
    it "creates a new shop" do
      shop_data = {
        reputation: 800
        location_attributes: location,
      }

      post "/shop", params: { shop: shop_data }

      if response.status == 422
        errors = JSON.parse(response.body)["errors"]
        puts "Validation errors: #{errors.join(', ')}" # Display the error messages
      end

      expect(response).to have_http_status(201)

When I make a POST using curl like the following:

curl -X POST \                   
  -H "Content-Type: application/json" \
  -d '{
    "shop": {
      "reputation": 800,
      "location_attributes": {
        "address": "My Street",
        "coordinates": [-100.0, 100.0],
        "place_id": "12345"
      },
    }
  }' \
  "http://localhost:3000/shop"

Everything works fine, but the test was failing with error code 422, i.e, the instance could not be stored. After a while I realized the issue: the coordinates array was not being processed the same way the reputation was being processed; the type of the values contained in the coordinates array was: Encoding, UTF8. enter image description here.

Also this is the value of params in the test:

{:shop=>{:price=>800, :location_attributes=>{:address=>"My Street", :coordinates=>[-100.0, 100.0], :place_id=>"12345"}}}

and this is the value of params in the controller:

{"shop"=>{"reputation"=>"800", "location_attributes"=>{"address"=>"My Street", "coordinates"=>["-100.0", "100.0"], "place_id"=>"12345"} }, "price"=>"800"}, "controller"=>"advert", "action"=>"create"}

last, this is the value of the params in the controller when I make the request using curl:

{"shop"=>{"reputation"=>800, "location_attributes"=>{"address"=>"My Street", "coordinates"=>[-100.0, 100.0], "place_id"=>"12345"}}, "controller"=>"advert", "action"=>"create"}

Obviously the tags are converted to Strings, but why do the integers and floats also get converted to strings when using post in the rspecs?

Thus, the validation in the location class was not successful. In order to fix this problem I had to modify the controller to the following: shop_controller.rb:

  def create
    shop = Shop.new(shop_params)
    shop.location.coordinates.map!(&:to_f)

    if shop.save
      render json: { message: 'Shop created successfully', shop: shop }, status: :created
    else
      render json: { errors: shop.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def shop_params
    params.require(:shop).permit(
      :reputation,
      location_attributes: [:address, :place_id, coordinates: []],
    )
  end

I don't understand why this is happening. Why does the parser interprets the content of the array as Encoded UTF8 data and not as Float values, the same way it does with the reputation field?

Also, is there a way to define shop_params? Why the following definition is not valid:

  def shop_params
    params.require(:shop).permit(
      :reputation,
      location_attributes: [:address, :place_id, :coordinates],
    )
  end

Solution

  • But why do the integers and floats also get converted to strings when using post in the rspecs?

    This has very little to do with RSpec.

    In your spec you're not actually sending a JSON request as the default for the post method is application/x-www-form-urlencoded (which is treated as the :html format in Rails).

    To send JSON use:

    post "/shop", params: { shop: shop_data }, format: :json
    

    This is actually a helper provided by the underlying ActionDispatch::IntegrationTest which RSpec just wraps.

    The resons you're now getting strings is that HTTP form data parameters are not actually typed. They are just pairs of keys and values in the form of strings.

    Furthermore your controller isn't actually restricting the request format to JSON which lets this bug slip through. I would use MimeResponds to make sure you get an ActionController::UnknownFormat exception instead.

    class ShopsController < ApplicationController
      # ...
      def create
        shop = Shop.new(shop_params)
        # don't do this - it's just silly to make the controller fix 
        # bad modeling 
        # shop.location.coordinates.map!(&:to_f)
    
        # this will raise if the client requests HTML
        respond_to :json do 
          if shop.save
            render json: { message: 'Shop created successfully', shop: shop }, status: :created
          else
            render json: { errors: shop.errors.full_messages }, status: :unprocessable_entity
          end
        end
      end
    end
    

    Using an array type is just a plain bad idea. I would just define two float type fields as its a lot less wonky and gives you typecasting and two separate attributes so that you can actually get the lat or lng in your code without pulling it out of an array.

    class Location
      include Mongoid::Document
    
      field :address, type: String
      field :place_id, type: String
      field :latitude, type: Float
      field :longitude, type: Float
    
      validates :longitude, :latitude, presence: true,
                                       numericality: true
    
      # Sets the latitude and longitude from an array or list
      def coordinates=(*args)
        self.latitude, self.longitude = *args.flatten
      end
    
      def coordinates
        [self.latitude, self.longitude]
      end
    end
    

    If you really want to accept the parameter as an array you need to whitelist it as such as well as arrays are not a permitted scalar type.

      def shop_params
        params.require(:shop).permit(
          :reputation,
          location_attributes: [
            :address, 
            :place_id, 
            coordinates: []
          ]
        )
      end