Search code examples
ruby-on-railsrubyobserver-patternactionviewhelper

RoR: undefined method `url_for' for nil:NilClass


I have a standard Rails application.

When a Tip is created, I would like to create a Message for each User who is interested in that Tip.

This sounds simple right? It should be...

So, we start with a Tip Observer:

class TipObserver < ActiveRecord::Observer
  def after_save(tip)
    # after the tip is saved, we'll create some messages to inform the users
    users = User.interested_in(tip) # get the relevant users
    users.each do |u|
      m = Message.new
      m.recipient = u
      link_to_tip = tip_path(tip)
      m.body = "Hello #{u.name}, a new tip: #{link_to_tip}"
      m.save!
    end
  end
end

Errors:

tip_observer.rb:13:in `after_save': undefined method `tip_path' for #<TipObserver:0xb75ca17c> (NoMethodError)

Ok, so TipObserver needs access to the UrlWriter methods. This should be fairly straightforward to fix, right?

class TipObserver < ActiveRecord::Observer
  include ActionController::UrlWriter

Now it runs(!) and Outputs:

Hello dave18, a new tip: /tips/511

Great that works!! Well it kinda, really we want that to be a click-able link. Again, that should be easy right?

link_to_tip = link_to tip.name, tip_path(tip)

Errors:

tip_observer.rb:13:in `after_save': undefined method `link_to' for #<TipObserver:0xb75f7708> (NoMethodError)

Ok, so this time TipObserver needs access to the UrlHelper methods. This should be fairly straightforward to fix, right?

class TipObserver < ActiveRecord::Observer
  include ActionController::UrlWriter
  include ActionView::Helpers::UrlHelper

Errors:

whiny_nil.rb:52:in `method_missing': undefined method `url_for' for nil:NilClass (NoMethodError)

Ok, it seems adding that has interfered with the url_for declaration. Lets try the includes in a different order:

class TipObserver < ActiveRecord::Observer
  include ActionView::Helpers::UrlHelper
  include ActionController::UrlWriter

Errors:

url_rewriter.rb:127:in `merge': can't convert String into Hash (TypeError)

Hmm, there's no obvious way around this. But after reading some clever-clogs suggestion that Sweepers are the same as Observers but have access to the url helpers. So lets convert the Observer to a Sweeper and remove the UrlHelper and UrlWriter.

class TipObserver < ActionController::Caching::Sweeper
  observe Tip
  #include ActionView::Helpers::UrlHelper
  #include ActionController::UrlWriter

Well, that allows it to run, but here's the Output:

Hello torey39, a new tip:

So, there's no error, but the url is not generated. Further investigation with the console reveals that:

tip_path => nil

and therefore:

tip_path(tip) => nil

Ok well I have no idea how to fix that problem, so perhaps we can attack this from a different direction. If we move the content into an erb template, and render the Message.body as a view - that gives two benefits - firstly the "View" content is put in the correct location, and it might help us avoid these *_path problems.

So lets change the after_save method:

def after_save(tip)
  ...
  template_instance = ActionView::Base.new(Rails::Configuration.new.view_path)
  m.body = template_instance.render(:partial => "messages/tip", :locals => { 
      :user=>user, 
      :tip=>tip
    })
  m.save!
end

Errors:

undefined method `url_for' for nil:NilClass (ActionView::TemplateError)

Great, but now we're back to this bloody url_for again. So this time its the ActionView thats complaining. Lets try and fix this then:

def after_save(tip)
  ...
  template_instance = ActionView::Base.new(Rails::Configuration.new.view_path)
  template_instance.extend ActionController::UrlWriter

Errors:

undefined method `default_url_options' for ActionView::Base:Class

Great so whatever we do we end up with errors. I've tried many many way of assigning default_url_options inside the template_instance without success.

So far this doesn't feel very "Railsy", in fact it feels downright difficult.

So my question is:

  • Am I trying to get a square peg in a round hole? If so, how should I adapt the architecture to provide this functionality? I can't believe its not something that exists in other websites.
  • Should I give up trying to use an Observer or Sweeper?
  • Should I be trying to create new Messages via the MessagesController, and if so, how can I invoke the MessagesController directly and multiple times from within the Observer/Sweeper?

Any tips advice or suggestions would be very gratefully recieved, I have been banging my head against this brick wall for several days now and slowly losing the will to live.

tia

Keith


Solution

  • Well you are right that your approach isn't very Rails-y. You are trying to mix model, controller and view methods in a way they aren't designed to and that's always a little shaky.

    If I had started down your path, I probably would have given up at the link_to problem and (I admit it isn't "the Rails way") coded the HTML for the link manually. So link_to_tip = link_to tip.name, tip_path(tip) becomes link_to_tip = '<a href="#{tip_path(tip)}">#{tip.name}</a> - a quick and dirty solution if you're looking for one ;-)

    But in my experience, Rails is pretty neat until you want to do things in a non-standard way. Then it can bite you :-)

    The problem is you are writing and storing text in your Message model which shouldn't be there. The Message model should belong_to Tips and a view should be responsible for presenting the message text, including the link to the tip. If a Message can be about something other than Tips, you can make a polymorphic association in the Message model like this:

    belongs_to :source, :polymorphic => true
    

    The Tip model would include:

    has_many :messages, :as => :source
    

    Then you do this (using your code as an example):

    m = Message.new
    m.source = tip
    m.save!
    

    The view which renders the message is then responsible for creating the link, like this:

    <%= "Hello #{u.name}, a new tip: #{link_to m.source.name, tip_path(m.source)}" %>