如何为has_many创建对称,干燥,RESTful的界面:通过关系?

时间:2012-04-13 21:04:25

标签: ruby-on-rails-3 rest routing has-many-through

假设标准has_many:通过三个模型之间的关系

class Person < ActiveRecord::Base
  has_many :memberships, :dependent => :destroy
  has_many :clubs, :through => :memberships
end
class Club < ActiveRecord::Base
  has_many :memberships, :dependent => :destroy
  has_many :persons, :through => :memberships
end
class Membership < ActiveRecord::Base
  belongs_to :person
  belongs_to :club
end

在API驱动的应用程序中,您希望公开以下URI:

  • 列出x人所属的俱乐部
  • 列出y俱乐部成员
  • (...和通常的CRUD方法集合......)

我的第一个想法是实现一对映射到MembersController的嵌套路由,如:

GET /clubs/:club_id/memberships     => members_controller#index
GET /persons/:person_id/memberships => members_controller#index

......但这里有点奇怪。

两个路由都映射到相同的members_controller方法(索引)。这没问题 - 我可以查看params哈希,看看是否给出了:club_id或a:person_id,并在members_controller表上应用了适当的范围。

但我不确定我们是否希望将Member对象暴露给最终用户。更直观的一对路线(至少从用户的角度来看)可能是:

GET /clubs/:club_id/persons   
GET /persons/:person_id/clubs 

......将返回一份人员名单和一份俱乐部名单(分别)。

但是如果你这样做,你会将这些路线映射到哪个控制器和动作? Rails中是否有任何提供指导的约定?或者这是否偏离了轨道,我应该以任何我认为合适的方式实施它?

3 个答案:

答案 0 :(得分:1)

我最终实现了完成以下任务的路由和控制器:

  • 完全是RESTful。
  • 完全对称:/ persons /:person_id /俱乐部/:club_id /会员资格与/ clubs /:club_id / persons /:person_id / memberships
  • 相同
  • 非常干。
  • 普通用户永远不会看到会员资格:id。相反,:person_id和:club_id用作引用特定成员资格的复合键。
  • 它允许通过以下方式直接访问Membership对象:id,但是为管理员保留。

以下是它识别的一些路线:

/persons/:person_id/clubs/:club_id/memberships - a specific membership association
/clubs/:club_id/persons/:person_id/memberships - equivalent
/persons/:person_id/clubs - all the clubs that a specific person belongs to
/clubs/:club_id/persons - all the persons that belong to a specific club
/memberships/:id - access to a specific membership (accessible only to admin)

请注意,在以下示例中,我没有包含用于身份验证和授权的Devise和CanCan构造。它们很容易添加。

这是路由文件:

# file: /config/routes.rb
Clubbing::Application.routes.draw do

  resources :persons, :except => [:new, :edit] do
    resources :clubs, :only => :index
  end

  resources :clubs, :except => [:new, :edit] do
    resources :persons, :only => :index
  end

  resources :memberships, :except => [:new, :edit]

  # there may be clever ways to specify these routes using #resources and
  # #collections and #member, but this ultimately is more straightforward

  match "/persons/:person_id/clubs/:club_id/memberships" => "memberships#create", :via => :post
  match "/persons/:person_id/clubs/:club_id/memberships" => "memberships#show", :via => :get
  match "/persons/:person_id/clubs/:club_id/memberships" => "memberships#update", :via => :put
  match "/persons/:person_id/clubs/:club_id/memberships" => "memberships#destroy", :via => :delete

  match "/clubs/:club_id/persons/:person_id/memberships" => "memberships#create", :via => :post
  match "/clubs/:club_id/persons/:person_id/memberships" => "memberships#show", :via => :get
  match "/clubs/:club_id/persons/:person_id/memberships" => "memberships#update", :via => :put
  match "/clubs/:club_id/persons/:person_id/memberships" => "memberships#destroy", :via => :delete

控制器非常简单。对于PersonsController和ClubsController,唯一不标准的是:index方法,我们在参数和范围中寻找:club_id或:person_id:

# file: /app/controllers/persons_controller.rb
class PersonsController < ApplicationController
  respond_to :json
  before_filter :locate_collection, :only => :index
  before_filter :locate_resource, :except => [:index, :create]

  def index
    respond_with @persons
  end

  def create
    @person = Person.create(params[:person])
    respond_with @person
  end

  def show
    respond_with @person
  end

  def update
    if @person.update_attributes(params[:person])
    end
    respond_with @person
  end

  def destroy
    @person.destroy
    respond_with @person
  end

  private

  def locate_collection
    if (params.has_key?("club_id"))
      @persons = Club.find(params[:club_id]).persons
    else
      @persons = Person.all
    end
  end

  def locate_resource
    @person = Person.find(params[:id])
  end

end

# file: /app/controllers/clubs_controller.rb
class ClubsController < ApplicationController
  respond_to :json
  before_filter :locate_collection, :only => :index
  before_filter :locate_resource, :except => [:index, :create]

  def index
    respond_with @clubs
  end

  def create
    @club = Club.create(params[:club])
    respond_with @club
  end

  def show
    respond_with @club
  end

  def update
    if @club.update_attributes(params[:club])
    end
    respond_with @club
  end

  def destroy
    @club.destroy
    respond_with @club
  end

  private

  def locate_collection
    if (params.has_key?("person_id"))
      @clubs = Person.find(params[:person_id]).clubs
    else
      @clubs = Club.all
    end
  end

  def locate_resource
    @club = Club.find(params[:id])
  end

end

MembershipsController只是稍微复杂一点:它在参数哈希中检测到:person_id和/或:club_id并相应地应用范围。如果同时存在:person_id和:club_id,我们可以假设它引用了唯一的成员资格对象:

# file: /app/controllers/memberships_controller.rb
class MembershipsController < ApplicationController
  respond_to :json
  before_filter :scope_collection, :only => [:index]
  before_filter :scope_resource, :except => [:index, :create]

  def index
    respond_with @memberships
  end

  def create
    @membership = scope_collection.create(params[:membership])
    respond_with @membership
  end

  def show
    respond_with @membership
  end

  def update
    if @membership.update_attributes(params[:membership])
    end
    respond_with @membership
  end

  def destroy
    @membership.destroy
    respond_with @membership
  end

  private

  # apply :person_id and/or :club_id scoping if present in params hash

  def scope_collection
    @memberships = scope_by_parameters
  end

  def scope_resource
    @membership = scope_by_parameters.first
  end

  def scope_by_parameters
    scope_by_param_id(scope_by_param_id(Membership.scoped, :person_id), :club_id)
  end

  def scope_by_param_id(relation, scope_name)
    (id = params[scope_name]) ? relation.where(scope_name => id) : relation
  end

end

答案 1 :(得分:0)

附录

(不是答案 - 只是观察)。这个问题的大部分都可以重新定义为“REST如何处理复合键?”

当您使用单个ID查找资源(如/ customers / 2141)时,REST理念很明确。当资源由复合键唯一定义时,做什么不太清楚:在上面的示例中,/ clubs /:club_id和/ persons /:person_id形成唯一标识成员资格的复合键。

关于此事,我还没有看过Roy Fielding's thoughts。这就是下一步。

答案 2 :(得分:0)

不管你在Rails中如何做到这一点,RESTful的关键是你可以拥有超链接。从一个人的角度来看会员名单是什么,但是他们所属的俱乐部的链接列表是什么? (好吧,每个都有一些额外的数据。)俱乐部的会员资格列表是什么,而不是作为会员的人的链接列表(同样,可能还有一些额外的数据)?

鉴于此,从一个人的角度来看,成员资格实际上是对会员资格表的看法(同样从俱乐部的角度来看; 在这个抽象层面没有区别)。棘手的是,当您更改某个人的成员资格时,您必须将更改通过视图推送回基础表。那仍然是RESTful;拥有RESTful资源并不意味着当没有给出直接的更改指令时资源永远不会改变。 (这会禁止各种有用的东西,比如共享资源!)确实,REST在这个领域的真正含义是客户端不应该假设它可以安全地缓存所有东西,除非托管服务明确说明它可以(通过合适的元数据/ HTTP标头)。