Roda plugin for RESTful APIs
Ruby
Switch branches/tags
Clone or download
Permalink
Failed to load latest commit information.
lib/roda pop options stack Jun 29, 2015
test few more tests Jul 1, 2015
.document add wrapper, id_pattern, global serialize Jun 27, 2015
.gitignore jeweler Jun 27, 2015
Gemfile add jeweler Jun 27, 2015
README.md update id pattern Jun 29, 2015
Rakefile rakefile Jun 29, 2015
VERSION v 2.0.1 Jun 29, 2015
roda-rest_api.gemspec Regenerate gemspec for version 2.0.1 Jun 29, 2015

README.md

Roda plugin for RESTful APIs

Quick start

Install gem with

gem 'roda-rest_api'          #Gemfile

or

gem install roda-rest_api    #Manual

Create rack app

#api.ru

require 'roda/rest_api'
require 'json'

class App < Roda
  
  plugin :rest_api
  
  route do |r|
    r.api do
      r.version 3 do
        r.resource :things do |things|
          things.list {|param| ['foo', 'bar']}
          things.routes :index
        end
      end
    end
  end
end

run App

And run with

bundle exec rackup api.ru

Try it out on:

curl http://127.0.0.1:9292/api/v3/things

Usage

route do |r|
  r.api path:'', subdomain:'api' do   # 'mount' on api.example.com/v1/...
    r.version 1 do

      #define all 7 routes:
      # index   - GET /v1/songs
      # show    - GET /v1/songs/:id
      # create  - POST /v1/songs
      # update  - PUT | PATCH /v1/songs/:id
      # destroy - DELETE /v1/songs/:id
      # edit    - GET /v1/songs/:id/edit
      # new     - GET /v1/songs/new
      
      # call permit to whitelist allowed parameters for save callback
      
      r.resource :songs do |songs|
        songs.list   { |params| Song.where(params).all }      #index
        songs.one    { |params| Song[params[:id]]  }          #show, edit, new
        songs.delete { |params| Song[params[:id]].destroy }   #destroy
        songs.save   { |atts|  Song.create_or_update(atts) }  #create, update
        songs.permit :title, author: [:name, :address]
      end

      #define 2 routes and custom serializer, custom primary key:
      # index   - GET /v1/artists
      # show    - GET /v1/artists/:id

      r.resource :artists, content_type: 'application/xml', primary_key: :artist_id do |artists|
        artists.list      { |params| Artist.where(params).all }
        artists.one       { |params| Artist[params[:artist_id]]  }
        artists.serialize { |result| ArtistSerializer.xml(result) }
        artists.routes :index, :show
      end
      
      #define 6 singleton routes:
      # show    - GET /v1/profile
      # create  - POST /v1/profile
      # update  - PUT | PATCH /v1/profile
      # destroy - DELETE /v1/profile
      # edit    - GET /v1/profile/edit
      # new     - GET /v1/profile/new
      
      r.resource :profile, singleton: true do |profile|
        profile.one     { |params| current_user.profile  }                      #show, edit, new
        profile.save    { |atts| current_user.profile.create_or_update(atts)  } #create, update
        profile.delete  { |params| current_user.profile.destroy  }              #destroy
        profile.permit :name, :address
      end

      #define nested routes
      # index   - GET /v1/albums/:parent_id/songs
      # show    - GET /v1/albums/:parent_id/songs/:id
      # index   - GET /v1/albums/:album_id/artwork
      # index   - GET /v1/albums/favorites
      # show    - GET /v1/albums/favorites/:id
      
      r.resource :albums do |albums|
        r.resource :songs do |songs|
          songs.list { |params| Song.where({ :album_id => params[:parent_id] }) }
          songs.one  { |params| Song[params[:id]] 	}
          songs.routes :index, :show
        end
        r.resource :artwork, parent_key: :album_id do |artwork|
          artwork.list { |params| Artwork.where({ :album_id => params[:album_id] }).all }
          artwork.routes :index
        end
        r.resource :favorites, bare: true do |favorites|
          favorites.list  { |params| Favorite.where(params).all  }
          favorites.one   { |params| Favorite[params[:id]] )  }
          favorites.routes :index, :show
        end
      end
      
      #call block before route is called
      
      r.resource :user, singleton: true do |user|
        user.save {|atts| User.create_or_update(atts) }
        user.routes :create    # public
        user.routes :update do # private
          authenticate!
        end
      end
      
      #define custom routes
      
      r.resource :albums do
        r.index do          # GET /v1/albums
          # list albums
        end
        r.create do         # POST /v1/albums
          # create album
        end
        r.show do |id|      # GET /v1/albums/:id
          # show album
        end
        r.update do |id|    # PATCH | PUT /v1/albums/:id
          # update album
        end
        r.destroy do |id|   # DELETE /v1/albums/:id
          # delete album
        end
        r.edit do |id|      # GET /v1/albums/:id/edit
          # edit album
        end
        r.new do            # GET /v1/albums/new
          # new album
        end
      end

    end
  end
end

###Options

The plugin supports several options serialize, content_type, wrapper and id_pattern to modify processing of the request. Besides these, any number of custom options can be passed, which can be handy for the wrapper option. Each option can be specified and overridden at the api, version or resource level.

####Serialization and content type

A serializer is an object that responds to :serialize and returns a string. Optionally it can provide the content_type, which may also be specified inline. Serialization can also be specified within a block inside the resource.

class XMLSerializer

  def serialize(result)   #required
    result.to_xml
  end

  def content_type        #optional
    'text/xml'
  end

end

class CSVSerializer

  def serialize(result)
    result.to_csv
  end

end


class App < Roda
    
  plugin :rest_api

  route do |r|
    r.api serializer: XMLSerializer.new
      r.resource :things do |things|
        things.list {|param| ['foo', 'bar']}
        things.routes :index
      end
      r.resource :objects, serializer: CSVSerializer.new, content_type: 'text/csv' do |objects|
        objects.one {|param| Object.find(param) }
        objects.routes :show
      end
      r.resource :items do |items|
        items.list {|param| Item.where(param) }
        items.routes :index
        items.serialize content_type: 'text/plain' do |result| #inline specification
          result.to_s
        end
      end

    end
  end
end

####Wrapper

A wrapper module can be specified, containing one or more 'around_*' methods. These methods should yield with the passed arguments. Wrappers can be used for cleaning up incoming parameters, database transactions, authorization checking or serialization. A resource can hold a generic :resource option, that can be used for providing extra info to the wrapper. It can be useful to set a custom option like model_class on a resource when using wrappers.

module Wrapper

  def around_save(atts)
    # actions before save
    result = yield(atts)  # call save action
    #actions after save
    result
  end
  
  # around_one, around_list, around_delete
end

module SpecialWrapper

  def around_delete(atts)
    model_class = opts[:model_class]
    if current_user.can_delete(model_class[atts[:id]])
      yield(atts)
    else
      #not allowed
    end
  end
  
  def current_user
    @request.current_user
  end

end

class App < Roda

  plugin :rest_api

  route do |r|
    r.api wrapper: Wrapper
      r.resource :things do |things|
        things.one {|params| Thing.find(params) }          # will be called inside the 'Wrapper#around_one' method
        things.list {|params| Thing.where(params) }        # will be called inside the 'Wrapper#around_list' method
        things.save {|atts| Thing.create_or_update(atts) } # will be called inside the 'Wrapper#around_save' method
        things.delete {|params| Thing.destroy(params) }    # will be called inside the 'Wrapper#around_delete' method
      end
      r.resource :items, wrapper: SpecialWrapper, resource: {model_class: Item} do |items|
        items.delete {|params| Item.destroy(params) }    # will be called inside the 'SpecialWrapper#around_delete' method
      end

    end
  end
end

####ID pattern

To support various id formats a regex can be specified to match custom id formats.

  uuid_pat = /(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})/
  r.api id_pattern: uuid_pat
    r.resource :things do |things|
      things.one {|params| Thing.find(params) }
      r.resource :parts, id_pattern: /part(\d+)/ do |parts|
        parts.one {|params| Part.find(params) }
      end

    end
  end
  
  # responds to /things/7e554915-210b-4dxe-a88b-3a09a5e790ge/parts/part123

Caveat

This plugin catches StandardError when performing the data access methods (list, one, save, delete) and will return a 404 or 422 response code when an error is thrown. When ENV['RACK_ENV'] is set to 'development' the error will be raised, but in all other cases it will fail silently. Be aware that ENV['RACK_ENV'] may be blank, so you won't see any errors even in development. A better approach is to develop and test the data access methods in isolation.