I’ve been recently working on a Slack bot and Slack delivers all events (webhooks) to the same URL that you can configure. That means you need to distinguish between different events types based on the payload (request body). In the past, I’ve seen that Rails apps usually reimplement their own “slack routing” within a single controller action. I wanted to share a different solution that I came up with. Let’s do routing in rails routing.

The idea is to use a custom rails constraints for that purpose. First we need a middleware that will parse the request body and store the info we need in the request env. Here is the middleware:

  class EventsMiddleware
    def initialize(app)
      @app = app
    end

    def call(env)
      request = Rack::Request.new(env)
      if request.path == '/slack/events'
        body = request.body.read
        request.body.rewind

        event_type = begin
          JSON.parse(body)['event']['type']
        rescue StandardError
          nil
        end
        env['slack.event.type'] = event_type
      end
      @app.call(env)
    end
  end

Now we can use this middleware in our config/application.rb:

config.middleware.use EventsMiddleware

Let’s define our custom constraint class to match against the event type:

  class EventsConstraint < BaseConstraint
    def self.[](*)
      new(*)
    end
    
    def initialize(expected_event_type)
      super()
      @expected_event_type = expected_event_type
    end

    def matches?(request)
      actual = request.env['slack.event.type'] # Set by EventsMiddleware
      actual == @expected_event_type
    end
  end

Finally, we can use this constraint in our routes:

Rails.application.routes.draw do
  post '/slack/events', 
    to: 'slack/messages#received',
    constraints: EventsConstraint['message']
  
  post '/slack/events',
    to: 'slack/messages#mentioned',
    constraints: EventsConstraint['app_mention']
  
  post '/slack/events',
    to: 'slack/home#opened',
    constraints: EventsConstraint['app_home_opened']
  
  post '/slack/events', 
    to: 'slack#events' # catch-all for remaining events
end

Single controller can handle either a single or multiple related event types, depending on your application domain.

I am not gonna show the whole code, but we went very similar path (middleware + constraints) with slack interactions, which are delivered to a separate URL:

post 'slack/interactions',
     to: 'slack/cancellation#initiated',
     constraints: BlockAction['cancel']

post 'slack/interactions',
     to: 'slack/cancellation#submitted',
     constraints: ViewSubmission['submit_cancellation']

I hope you find this approach useful. I find it more Rails way than reimplementing the routing logic in the controller. The only downside that I see is that the JSON body is parsed twice. However, this takes around 0.05ms, so that’s not a real performance problem compared to i.e. a single DB query.

We are testing this using request specs, which goes through the whole stack and ensures that the routing works as expected.

Want more? Ruby Under Pressure Newsletter