Dynamic actions in Ruby on Rails to DRY and automate

The problem:

I wanted to create a library of markdown files, which would be rendered to a user on my website as individual pages. It was all about documents about network services e.g. SMTP, DNS, HTTP etc., each one having its own markdown file. The amount of services/files was unknown, as there were lots that have been already added and more that would be added in the future. No much thinking is necessary for 5 actions but imagine If there are… 100 of them.

The solution:

I chose Markdown format because I am not working with a database at the moment and I hate the over-engineering of things. Choosing redcarpet and rouge was an easy thing as they have a strong community to support them. I used dynamic actions/method creation in order to reduce useless code bloat. At the end, I just have to drop a markdown file in the directory mentioned and that’s it! *note: as with any other problem, there isn’t only one solution. The purpose there is to use dynamic actions intentionally to solve this one.


The markdown files

First of all I created a directory in the /public/md/ Rails path. I copied my .md files there, keeping the name of the service as the filename.

The routes

The usual path was to create a GET route for each service, setting up its own action in the controller and thus bloat and code smells. So I created a route for all of the services:

resources :services do
  get '/:service', on: :collection, action: 'service'
end

The controller

Next, I started building the controller. First the markdown processing:

# Reads markdown files from public directory path
  def read_md_data(service)
    path = File.join(Rails.root, 'public/md/' + service + '.md')
    return File.read(path)
  rescue NilClass
    return nil
  end
# Renders text to syntax highlighted text
  def create_content(data)
    return Redcarpet::Markdown.new(
            ::PageRender, fenced_code_blocks: true)
            .render(data)
            .html_safe
  rescue NoMethodError, NilClass
    return ''
  end

Then I added the respond_to_missing? override and I used the method_missing method in order to accept services as service_<name>:

# Overrides default respond_to_missing? method so it returns false # when it doesn't respond to a missing service method
  def respond_to_missing?(method_name, include_private = true)
    !method_name.match(/^service_[a-z0-9]+$/).nil?
  end
# Takes actions to render markdown text to the browser
  def method_missing(method_name, *arguments)
    service = method_name.match(/^service_([a-z0-9]+)$/)[1]
    data = read_md_data(service)
    @content = create_content(data)
    render :service_page
  rescue NoMethodError, NilClass
    @content = 'ERROR: There was an issue while rendering content'
  end

Next thing, I built the main service action. I chose this RegEx because there are services which carry a number next to the name e.g. POP3:

# Calls a dynamic method "service_<name> If validated properly"
  def service
    if params[:service] && params[:service].match(/^[a-z0-9]+$/)
      self.send("service_#{params[:service]}")
    else
      raise StandardError
    end
  rescue StandardError
    @content = "ERROR: Page for #{params[:service]} is missing"
    render :service_page
  end

Finally, the action_missing method:

# Calls service method If overridden respond_to_missing? == true
  def action_missing(name)
    self.send(name) if self.send(:respond_to_missing?, name)
  end

All the controller code in one block:

class ServicesController < ApplicationController
# Calls a dynamic method "service_<name> If validated properly"
  def service
    if params[:service] && params[:service].match(/^[a-z0-9]+$/)
      self.send("service_#{params[:service]}")
    else
      raise StandardError
    end
  rescue StandardError
    @content = "ERROR: Page for #{params[:service]} is missing"
    render :service_page
  end
# Calls service method If overridden respond_to_missing? returns true
  def action_missing(name)
    self.send(name) if self.send(:respond_to_missing?, name)
  end
private
# Reads markdown files from public directory path
  def read_md_data(service)
    path = File.join(Rails.root, 'public/md/' + service + '.md')
    return File.read(path)
  rescue NilClass
    return nil
  end
# Renders text to syntax highlighted text
  def create_content(data)
    return Redcarpet::Markdown.new(
            ::PageRender, fenced_code_blocks: true)
            .render(data)
            .html_safe
  rescue NoMethodError, NilClass
    return ''
  end
# Overrides default respond_to_missing? method so it returns false # when it doesn't respond to a missing service method
  def respond_to_missing?(method_name, include_private = true)
    !method_name.match(/^service_[a-z0-9]+$/).nil?
  end
# Takes actions to render markdown text to the browser
  def method_missing(method_name, *arguments)
    service = method_name.match(/^service_([a-z0-9]+)$/)[1]
    data = read_md_data(service)
    @content = create_content(data)
    render :service_page
  rescue NoMethodError, NilClass
    @content = 'ERROR: There was an issue while rendering content'
  end
end

The view

The view is pretty simple (bootstrap 4 used):

<div class="container-fluid">
  <h2> <strong>Services</strong> </h2>
  <hr>
</div>
<div class="container">
  <div class="row">
    <div class="col">
      <%= @content %>
    </div>
  </div>
</div>

The verdict

I know that it’s not perfect and the code can be reduced more in size, but that’s my first version of the implementation. To be honest, it didn’t take more than an hour for me to set this up so I will revise the code again and make the appropriate changes. Hope this helps someone.


References: http://www.cyberciti.biz/faq/apple-mac-osx-nfs-mount-command-tutorial