How to flatten a JSON response when using ActiveResource? - ruby-on-rails

I have an API and a client app, and I am using rails with ActiveResource.
I have a Recruiter model that inherits from ActiveResource::Base
Let's say on the client side I write:
dave = Recruiter.new(email: "email#recruiter.com", password: "tyu678$--è", full_name: "David Blaine", company: "GE")
dave.save
The request I send is formatted like so:
{"recruiter":{
"email": "email#recruiter.com",
"password": "tyu678$--è",
"full_name": "David Blaine",
"company": "GE"
}
}
and the Json response I get from the API is formatted like:
{"recruiter":{
"email": "email#recruiter.com",
"password": "tyu678$--è",
"full_name": "David Blaine",
"company": "GE",
"foo": "bar"
},
"app_token":"ApfXy8YYVtsipFLvJXQ"
}
The problem is that this will let me access the app token with dave.app_token but I can't for instance write dave.foo, which raises an error.
Is there a way to flatten the response or read through it recursively si that I can access all of my instance's attributes while keeping the API response formatted as it is?

Looking through the whole ActiveResource process, you can overwrite the load method in your Recruiter model.
I just added the code in the #HACK section which "flatten" your attributes.
def load(attributes, remove_root = false, persisted = false)
raise ArgumentError, "expected an attributes Hash, got #{attributes.inspect}" unless attributes.is_a?(Hash)
#prefix_options, attributes = split_options(attributes)
# HACK
if !attributes[:app_token].nil?
attributes[:recruiter]["app_token"] = attributes[:app_token]
attributes = attributes[:recruiter]
end
# /HACK
if attributes.keys.size == 1
remove_root = self.class.element_name == attributes.keys.first.to_s
end
attributes = ActiveResource::Formats.remove_root(attributes) if remove_root
attributes.each do |key, value|
#attributes[key.to_s] =
case value
when Array
resource = nil
value.map do |attrs|
if attrs.is_a?(Hash)
resource ||= find_or_create_resource_for_collection(key)
resource.new(attrs, persisted)
else
attrs.duplicable? ? attrs.dup : attrs
end
end
when Hash
resource = find_or_create_resource_for(key)
resource.new(value, persisted)
else
value.duplicable? ? value.dup : value
end
end
self
end

Related

How to transform nested parameters in Rails API for PATCH requests

I'm having problems trying to implement a PATCH endpoint for a Rails API which deals with complex request objects that are structurally different from the ActiveRecord model.
As an example let's say I have the following request object:
{
"details": {
"color": {
"id": 1
}
},
"name": "Hello, world!"
...
}
However, on my model I expect a flat color_id attribute:
class CreateModel < ActiveRecord::Migration[7.0]
def change
create_table :model do |t|
t.string :name, null: false
t.integer :color_id, null: false
end
end
end
Therefore I need to transform the request params. For this I've found one approach which works pretty well in case of PUT requests, but not at all for PATCH:
ActionController::Parameters.new({
color_id: params.dig(:details, :color, :id),
name: params.dig(:name)
})
If I issue a PUT request this solution works great since PUT expects the whole object as payload, PATCH on the other hand would cause issues when passing only a subset of the properties since everything else will be set to nil due to how dig works.
Assuming I have no control over the request format, how can I transform the request params in the backend so that omitted keys will not result in nil values? Of course I could imperatively handle each property line by line, checking whether the key is present in the original params and then setting it in the new one, but is there a more elegant approach?
I've found a generic solution using mapping logic with a lookup table. For the example above:
{
"details": {
"color": {
"id": 1
}
},
"name": "Hello, world!"
...
}
I would have the following mapping variable:
MAPPING = {
[:details, :color, :id] => [:color_id]
}
Then I'm able to transform the params using this recursive algorithm:
def handle(params, keys)
output = Hash.new
params.each do |k,v|
sym_keys = (keys + [k]).map &:to_sym
target_keys = MAPPING[sym_keys]
if v.is_a? ActionController::Parameters
keys << k
output = output.deep_merge! transform(v, keys)
else
if target_keys.nil?
value = sym_keys.reverse().reduce(v) { |v, k| Hash[k, v] }
else
value = target_keys.reverse().reduce(v) { |v, k| Hash[k, v] }
end
output = output.deep_merge! value
end
end
output
end
def transform(params)
output = handle(params, [])
end

Why am I getting "no implicit conversion of String into Integer" when trying to get Nested JSON attribute?

My Rails app is reading in JSON from a Bing API, and creating a record for each result. However, when I try to save one of the nested JSON attributes, I'm getting Resource creation error: no implicit conversion of String into Integer.
The JSON looks like this:
{
"Demo": {
"_type": "News",
"readLink": "https://api.cognitive.microsoft.com/api/v7/news/search?q=european+football",
"totalEstimatedMatches": 2750000,
"value": [
{
"provider": [
{
"_type": "Organization",
"name": "Tuko on MSN.com"
}
],
"name": "Hope for football fans as top European club resume training despite coronavirus threat",
"url": "https://www.msn.com/en-xl/news/other/hope-for-football-fans-as-top-european-club-resume-training-despite-coronavirus-threat/ar-BB12eC6Q",
"description": "Bayern have returned to training days after leaving camp following the outbreak of coronavirus. The Bundesliga is among top European competitions suspended."
}
}
The attribute I'm having trouble with is [:provider][:name].
Here's my code:
def handle_bing
#terms = get_terms
#terms.each do |t|
news = get_news(t)
news['value'].each do |n|
create_resource(n)
end
end
end
def get_terms
term = ["European football"]
end
def get_news(term)
accessKey = "foobar"
uri = "https://api.cognitive.microsoft.com"
path = "/bing/v7.0/news/search"
uri = URI(uri + path + "?q=" + URI.escape(term))
request = Net::HTTP::Get.new(uri)
request['Ocp-Apim-Subscription-Key'] = accessKey
response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
http.request(request)
end
response.each_header do |key, value|
# header names are coerced to lowercase
if key.start_with?("bingapis-") or key.start_with?("x-msedge-") then
puts key + ": " + value
end
end
return JSON(response.body)
end
def create_resource(news)
Resource.create(
name: news['name'],
url: news['url'],
description: news['description'],
publisher: news['provider']['name']
)
end
I looked at these questions, but they didn't help me:
Extract specific field from JSON nested hashes
No implicit conversion of String into Integer (TypeError)?
Why do I get "no implicit conversion of String into Integer (TypeError)"?
UPDATE:
I also tried updating the code to:
publisher: news['provider'][0]['name'], but I received the same error.
because "provider" is an array.
it should be accessed with index.
[:value][0][:provider][0][:name]
same goes with "value".

Stringify/Parse request data through rspec

I'm submitting requests on the frontend where I stringify my data (array of objects) and then parse it in the backend.
When I run my specs, I'm getting the error no implicit conversion of Array into String
How can I stringify my data in my spec so that it's consistent with what I'm doing in the frontend? Or is there another way where I don't have to stringify/parse my data to handle all of this?
This is how my frontend data structure looks like:
"categories_and_years": JSON.stringify(
[
{"category_id": 1, "year_ids":[1, 2, 3]},
{"category_id": 2, "year_ids":[2, 3]},
]
)
In my controller, I'm validating the data is an array first:
def validate_categories_and_years_array
#cats_and_yrs = JSON.parse(params[:categories_and_years])
return unless #cats_and_yrs
if !#cats_and_yrs.is_a?(Array)
render_response(:unprocessable_entity, { description_detailed: "categories_and_years must be an array of objects"})
end
end
In my specs, I'm setting my params like this:
context "when all categories and years are valid" do
let(:params) do
{
school_id: school.id,
id: standard_group.id,
categories_and_years: [
{ category_id: category_1.id, year_ids: [ year_1.id ] }
]
}
end
it "adds standards from specific categories and years to the school" do
post :add, params: params, as: :json
expect(school.achievement_standards).to contain_exactly( std_1 )
end
end
This post explains the difference between a regular ruby hash which you have in your spec and a HashWithIndifferentAccess.
Can you also try to_json?
Params hash keys as symbols vs strings
params = ActiveSupport::HashWithIndifferentAccess.new()
params['school_id'] = school.id
params['id'] = standard_group.id
params['categories_and_years'] = [
{ category_id: category_1.id, year_ids: [ year_1.id ] }
]
params = params.to_json
let(:params) { params }

Ruby 2.4/Rails 5: making a recursive array of hashes, deleting if key is blank

I've got a class that looks like this that turns a collection into a nested array of hashes:
# variable_stack.rb
class VariableStack
def initialize(document)
#document = document
end
def to_a
#document.template.stacks.map { |stack| stack_hash(stack) }
end
private
def stack_hash(stack)
{}.tap do |hash|
hash['stack_name'] = stack.name.downcase.parameterize.underscore
hash['direction'] = stack.direction
hash['boxes'] = stack.boxes.indexed.map do |box|
box_hash(box)
end.reverse_if(stack.direction == 'up') # array extensions
end.delete_if_key_blank(:boxes) # hash extensions
end
def box_hash(box)
{}.tap do |hash|
hash['box'] = box.name.downcase.parameterize.underscore
hash['content'] = box.template_variables.indexed.map do |var|
content_array(var)
end.join_if_any?
end.delete_if_key_blank(:content)
end
def content_array(var)
v = #document.template_variables.where(master_id: var.id).first
return unless v
if v.text.present?
v.text
elsif v.photo_id.present?
v.image.uploaded_image.url
else
''
end
end
end
# array_extensions.rb
class Array
def join_if_any?
join("\n") if size.positive?
end
def reverse_if(boolean)
reverse! if boolean
end
end
# hash_extensions.rb
class Hash
def delete_if_key_blank(key)
delete_if { |_, _| key.to_s.blank? }
end
end
This method is supposed to return a hash that looks like this:
"stacks": [
{
"stack_name": "stack1",
"direction": "down",
"boxes": [
{
"box": "user_information",
"content": "This is my name.\n\nThis is my phone."
}
},
{
"stack_name": "stack2",
"direction": "up",
"boxes": [
{
"box": "fine_print",
"content": "This is a test.\n\nYeah yeah."
}
]
}
Instead, often the boxes key is null:
"stacks": [
{
"stack_name": "stack1",
"direction": "down",
"boxes": null
},
{
"stack_name": "stack2",
"direction": "up",
"boxes": [
{
"box": "fine_print",
"content": "This is a test.\n\nYeah yeah."
}
]
}
I suspect it's because I can't "single-line" adding to arrays in Rails 5 (i.e., they're frozen). The #document.template.stacks is an ActiveRecord collection.
Why can't I map records in those collections into hashes and add them to arrays like hash['boxes']?
The failing test
APIDocumentV3 Instance methods #stacks has the correct content joined and indexed
Failure/Error:
expect(subject.stacks.first['boxes'].first['content'])
.to include(document.template_variables.first.text)
expected "\n" to include "#1"
Diff:
## -1,2 +1 ##
-#1
The presence of \n means the join method works, but it shouldn't join if the array is empty. What am I missing?
reverse_if returns nil if the condition is false. Consider this:
[] if false #=> nil
You could change it like this:
def reverse_if(condition)
condition ? reverse : self
end
delete_if_key_blank doesn't look good for me. It never deletes anything.
Disclaimer. I don't think it's a good idea to extend standard library.
So thanks to Danil Speransky I solved this issue, although what he wrote doesn't quite cover it.
There were a couple of things going on here and I solved the nil arrays with this code:
hash['boxes'] = stack.boxes.indexed.map do |box|
box_hash(box) unless box_hash(box)['content'].blank?
end.reverse_if(stack.direction == 'up').delete_if_blank?
end
That said, I'm almost certain my .delete_if_blank? extension to the Array class isn't helping at all. It looks like this, FYI:
class Array
def delete_if_blank?
delete_if(&:blank?)
end
end
I solved it by thowing the unless box_hash(box)['content'].blank? condition on the method call. It ain't pretty but it works.

Convert json to hash to insert with Active Record

I'm still practising processing arrays and hashes, and particularly getting to details in 2d or 3d structures. I'm trying to use details in a json file to process some data ready to insert into the db with Active Record.
Here is my json structure for 'my_file.json'
# my_file.json
[
{
"name": "Joe Bloggs",
"telephone": "012-345-6789"
},
{
"name": "Hilda Bloggs",
"telephone": "012-345-6789"
}
]
and here is the code I'm using to convert the json data into something I can insert into my db
def json_insert_to_db
require 'json'
file = File.read('my_file.json')
json_data = JSON.parse(file)
details = json_data.map do |x|
user = User.new
user.name = json_data[x]['name']
user.telephone = json_data[x]['telephone']
end
end
With this I get
NameError: uninitialized constant User
(User does exist in the database, by the way)
I can't work out where I'm going wrong, but I know it's something simple I am overlooking. Thanks for any help.
The User model was set up but my db had a migration issue. However, beyond that, at the time I was still unable to build what I needed when importing the json file.
I have now worked out how to do it. The migration issue was resolved and I also revised the structure of my json file first, for clarity.
# my_file.json
{
"new_users":
[{
"name": "Joe Bloggs",
"telephone": "012-345-6789",
},
{
"name": "Hilda Bloggs",
"telephone": "012-345-6789",
}]
}
And my script...
require 'json'
file = File.read('my_file.json')
json_data = JSON.parse(file)['new_users']
#new_users = json_data.each do |key,value|
#new_user = User.new
#new_user.name = key['name']
#new_user.telephone = key['telephone']
end
#new_users.each { |x| puts x }

Resources