ruby-on-railsjson-apijsonapi-resourcesfastjsonapijsonapi-serialize

How to save a nested many-to-many relationship in API-only Rails?


In my Rails (api only) learning project, I have 2 models, Group and Artist, that have a many-to-many relationship with a joining model, Role, that has additional information about the relationship. I have been able to save m2m relationships before by saving the joining model by itself, but here I am trying to save the relationship as a nested relationship. I'm using the jsonapi-serializer gem, but not married to it nor am I tied to the JSON api spec. Getting this to work is more important than following best practice.

With this setup, I'm getting a 500 error when trying to save with the following errors: Unpermitted parameters: :artists, :albums and ActiveModel::UnknownAttributeError (unknown attribute 'relationships' for Group.)

I'm suspecting that my problem lies in the strong param and/or the json payload.

Models

class Group < ApplicationRecord
  has_many :roles
  has_many :artists, through: :roles

  accepts_nested_attributes_for :artists, :roles
end


class Artist < ApplicationRecord
  has_many :groups, through: :roles
end


class Role < ApplicationRecord
  belongs_to :artist
  belongs_to :group
end

Controller#create

def create
  group = Group.new(group_params)

  if group.save
    render json: GroupSerializer.new(group).serializable_hash
  else
    render json: { error: group.errors.messages }, status: 422
  end
end

Controller#group_params

def group_params
  params.require(:data)
    .permit(attributes: [:name, :notes],
      relationships: [:artists])
end

Serializers

class GroupSerializer
  include JSONAPI::Serializer
  attributes :name, :notes

  has_many :artists
  has_many :roles
end


class ArtistSerializer
  include JSONAPI::Serializer
  attributes :first_name, :last_name, :notes
end


class RoleSerializer
  include JSONAPI::Serializer
  attributes :artist_id, :group_id, :instruments
end

Example JSON payload

{
  "data": {
    "attributes": {
      "name": "Pink Floyd",
      "notes": "",
    },
    "relationships": {
      "artists": [{ type: "artist", "id": 3445 }, { type: "artist", "id": 3447 }]
    }
}

Additional Info

It might help to know that I was able to save another model with the following combination of json and strong params.

# Example JSON

"data": {
  "attributes": {
    "title": "Wish You Were Here",
    "release_date": "1975-09-15",
    "release_date_accuracy": 1
    "notes": "",
    "group_id": 3455
  }
}


# in albums_controller.rb 

def album_params
  params.require(:data).require(:attributes)
    .permit(:title, :group_id, :release_date, :release_date_accuracy, :notes)
end

Solution

  • From looking at https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html I think the data format that Rails is normally going to expect will look something like:

    {
      "group": {
        "name": "Pink Floyd",
        "notes": "",
        "roles_attributes": [
          { "artist_id": 3445 },
          { "artist_id": 3447 }
        ]
      }
    }
    

    with a permit statement that looks something like (note the . before permit has moved):

    params.require(:group).
        permit(:name, :notes, roles_attributes: [:artist_id])
    

    I think you have a few options here:

    1. Change the data format coming into the action.
    2. Craft a permit statement that works with your current data (not sure how tricky that is), you can test your current version in the console with:
    params = ActionController::Parameters.new({
      "data": {
        "attributes": {
          "name": "Pink Floyd",
          "notes": "",
        },
        "relationships": {
          "artists": [{ type: "artist", "id": 3445 }, { type: "artist", "id": 3447 }]
        }
      }
    })
    
    group_params = params.require(:data).
        permit(attributes: [:name, :notes],
          relationships: [:artists])
    
    group_params.to_h.inspect
    

    and then restructure the data to a form the model will accept; or

    1. Restructure the data before you try to permit it e.g. something like:
    def group_params
        params_hash = params.to_unsafe_h
    
        new_params_hash = {
          "group": params_hash["data"]["attributes"].merge({
            "roles_attributes": params_hash["data"]["relationships"]["artists"].
                map { |a| { "artist_id": a["id"] } }
          })
        }
    
        new_params = ActionController::Parameters.new(new_params_hash)
    
        new_params.require(:group).
            permit(:name, :notes, roles_attributes: [:artist_id])
    end
    

    But ... I'm sort of hopeful that I'm totally wrong and someone else will come along with a better solution to this stuff.