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.
.
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
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