Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

initial checkin

  • Loading branch information...
commit f23a1e37fe0e377d47a1c1478746a0b269d50894 0 parents
Nick Urban nickurban authored
5 .document
... ... @@ -0,0 +1,5 @@
  1 +lib/**/*.rb
  2 +bin/*
  3 +-
  4 +features/**/*.feature
  5 +LICENSE.txt
49 .gitignore
... ... @@ -0,0 +1,49 @@
  1 +# rcov generated
  2 +coverage
  3 +coverage.data
  4 +
  5 +# rdoc generated
  6 +rdoc
  7 +
  8 +# yard generated
  9 +doc
  10 +.yardoc
  11 +
  12 +# bundler
  13 +.bundle
  14 +
  15 +# jeweler generated
  16 +pkg
  17 +
  18 +# Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
  19 +#
  20 +# * Create a file at ~/.gitignore
  21 +# * Include files you want ignored
  22 +# * Run: git config --global core.excludesfile ~/.gitignore
  23 +#
  24 +# After doing this, these files will be ignored in all your git projects,
  25 +# saving you from having to 'pollute' every project you touch with them
  26 +#
  27 +# Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line)
  28 +#
  29 +# For MacOS:
  30 +#
  31 +#.DS_Store
  32 +
  33 +# For TextMate
  34 +#*.tmproj
  35 +#tmtags
  36 +
  37 +# For emacs:
  38 +#*~
  39 +#\#*
  40 +#.\#*
  41 +
  42 +# For vim:
  43 +*.swp
  44 +
  45 +# For redcar:
  46 +#.redcar
  47 +
  48 +# For rubinius:
  49 +#*.rbc
1  .rspec
... ... @@ -0,0 +1 @@
  1 +--color
27 Gemfile
... ... @@ -0,0 +1,27 @@
  1 +source "http://rubygems.org"
  2 +# Add dependencies required to use your gem here.
  3 +# Example:
  4 +# gem "activesupport", ">= 2.3.5"
  5 +
  6 +gem 'json'
  7 +gem 'activemodel'
  8 +gem 'activeresource'
  9 +gem 'uuidtools'
  10 +
  11 +gem 'rest-client'
  12 +gem 'rest-client-components'
  13 +
  14 +# Add dependencies to develop your gem here.
  15 +# Include everything needed to run rake, tests, features, etc.
  16 +group :development do
  17 + gem "rspec", "~> 2.8.0"
  18 + gem "rdoc", "~> 3.12"
  19 + gem "bundler", ">= 1.0.0"
  20 + gem "jeweler", "~> 1.8.3"
  21 + gem "rcov", ">= 0"
  22 +
  23 + gem 'autotest'
  24 + gem 'fakeweb'
  25 + gem 'fakeweb-matcher'
  26 + #gem 'rack-cache'
  27 +end
69 Gemfile.lock
... ... @@ -0,0 +1,69 @@
  1 +GEM
  2 + remote: http://rubygems.org/
  3 + specs:
  4 + ZenTest (4.6.2)
  5 + activemodel (3.2.2)
  6 + activesupport (= 3.2.2)
  7 + builder (~> 3.0.0)
  8 + activeresource (3.2.2)
  9 + activemodel (= 3.2.2)
  10 + activesupport (= 3.2.2)
  11 + activesupport (3.2.2)
  12 + i18n (~> 0.6)
  13 + multi_json (~> 1.0)
  14 + autotest (4.4.6)
  15 + ZenTest (>= 4.4.1)
  16 + builder (3.0.0)
  17 + diff-lcs (1.1.3)
  18 + fakeweb (1.3.0)
  19 + fakeweb-matcher (1.2.2)
  20 + fakeweb (>= 1.2.5)
  21 + rspec (>= 1.2.0)
  22 + git (1.2.5)
  23 + i18n (0.6.0)
  24 + jeweler (1.8.3)
  25 + bundler (~> 1.0)
  26 + git (>= 1.2.5)
  27 + rake
  28 + rdoc
  29 + json (1.6.5)
  30 + mime-types (1.17.2)
  31 + multi_json (1.1.0)
  32 + rack (1.4.1)
  33 + rake (0.9.2.2)
  34 + rcov (1.0.0)
  35 + rdoc (3.12)
  36 + json (~> 1.4)
  37 + rest-client (1.6.7)
  38 + mime-types (>= 1.16)
  39 + rest-client-components (1.2.0)
  40 + rack (>= 1.0.1)
  41 + rest-client (>= 1.6.0, < 1.7.0)
  42 + rspec (2.8.0)
  43 + rspec-core (~> 2.8.0)
  44 + rspec-expectations (~> 2.8.0)
  45 + rspec-mocks (~> 2.8.0)
  46 + rspec-core (2.8.0)
  47 + rspec-expectations (2.8.0)
  48 + diff-lcs (~> 1.1.2)
  49 + rspec-mocks (2.8.0)
  50 + uuidtools (2.1.2)
  51 +
  52 +PLATFORMS
  53 + ruby
  54 +
  55 +DEPENDENCIES
  56 + activemodel
  57 + activeresource
  58 + autotest
  59 + bundler (>= 1.0.0)
  60 + fakeweb
  61 + fakeweb-matcher
  62 + jeweler (~> 1.8.3)
  63 + json
  64 + rcov
  65 + rdoc (~> 3.12)
  66 + rest-client
  67 + rest-client-components
  68 + rspec (~> 2.8.0)
  69 + uuidtools
20 LICENSE.txt
... ... @@ -0,0 +1,20 @@
  1 +Copyright (c) 2012 Deep Web Technologies, inc.
  2 +
  3 +Permission is hereby granted, free of charge, to any person obtaining
  4 +a copy of this software and associated documentation files (the
  5 +"Software"), to deal in the Software without restriction, including
  6 +without limitation the rights to use, copy, modify, merge, publish,
  7 +distribute, sublicense, and/or sell copies of the Software, and to
  8 +permit persons to whom the Software is furnished to do so, subject to
  9 +the following conditions:
  10 +
  11 +The above copyright notice and this permission notice shall be
  12 +included in all copies or substantial portions of the Software.
  13 +
  14 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  15 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  16 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  17 +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
  18 +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
  19 +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
  20 +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
140 README.rdoc
Source Rendered
... ... @@ -0,0 +1,140 @@
  1 += well_rested
  2 +
  3 +WellRested (WR) is a resource-based REST-API client library for Ruby.
  4 +
  5 +Like ActiveResource, it supports ActiveRecord-style validations and works with Rails form helpers.
  6 +
  7 +WellRested was created to address some limitations in ActiveResource:
  8 +
  9 +- Supports transparent HTTP caching (via Rack::Cache / rest-client-components).
  10 +- Avoids race conditions when changing HTTP authentication by specifying it on an API object rather than in the Base class.
  11 +- Easy to override the object loading / serializing methods to modify attributes on the way in or out.
  12 +- Handles camel-cased attribute and resource names.
  13 +- Will not put file-extensions (e.g. .json) on the end of paths unless you tell it to.
  14 +
  15 +== Features to Add
  16 +
  17 +- XML support. Currently, only JSON is supported.
  18 +
  19 +If you want to use WR, but are encountering a limitation, I encourage you to file an issue on GitHub
  20 +or to fork your own version and send a pull request with your changes.
  21 +
  22 +== Installation
  23 +
  24 + gem install well_rested
  25 +
  26 +Or, if using bundler, add the following to your Gemfile
  27 +
  28 + gem 'well_rested'
  29 +
  30 +Then
  31 +
  32 + bundle install
  33 +
  34 +== Sample Usage with Rails
  35 +
  36 +In app/models/base.rb
  37 +
  38 + # It is often convenient to set your defaults in a base class.
  39 + class Base < WellRested::Base
  40 + self.protocol = 'https'
  41 + self.server = 'example.com:8888'
  42 +
  43 + # Send and receive request bodies in JSON. This is the default.
  44 + self.body_formatter = WellRested::JSONFormatter.new
  45 + self.extension = '.json' # add '.json' to the end of URLs. Default is an empty string.
  46 +
  47 + # Encode multi-word attributes names into lowerCamelCase. This is the default.
  48 + self.attribute_formatter = WellRested::CamelCaseFormatter.new(:lower)
  49 + end
  50 +
  51 +In app/models/my_resource.rb
  52 +
  53 + class MyResource < Base
  54 + self.path = '/users/:user_id/my_resources' # NOTE: Path must begin with a slash!
  55 +
  56 + define_schema :id, :user_id, :name, :status => 'active' # status defaults to 'active'
  57 +
  58 + # ActiveModel validations may be applied here.
  59 + # They will be checked before a save or create request is made.
  60 + validates :name, :length => { :maximum => 127 }, :presence => true
  61 + end
  62 +
  63 +In app/controllers/application_controller.rb
  64 +
  65 + before_filter :load_api
  66 +
  67 + def load_api
  68 + @api = WellRested::API.new
  69 + # set basic auth
  70 + @api.user = 'username'
  71 + @api.password = 'pass'
  72 + end
  73 +
  74 +In app/controllers/my_resources_controller.rb
  75 +
  76 + def index
  77 + @my_resources = @api.find_many(MyResource, :user_id => current_user.id)
  78 + end
  79 +
  80 + def create
  81 + @my_resource = MyResource.new(params[:my_resource])
  82 + @my_resource.user_id = current_user.id
  83 +
  84 + # If current_user.id is 22, then this will POST to https://example.com:8888/users/22/my_resources
  85 + # with a JSON payload e.g. '{"name":"My Resource","user_id":22,"status":"active"}'
  86 + if @api.save(@my_resource)
  87 + flash[:notice] = 'Created resource!'
  88 + redirect_to my_resources_path
  89 + else
  90 + flash[:warning] = 'Error creating resource!'
  91 + render :new
  92 + end
  93 + end
  94 +
  95 + def update
  96 + @my_resource = MyResource.new(params[:my_resource])
  97 +
  98 + # Assuming @my_resource.user_id is 22 and @my_resource.id is 41
  99 + # If valid, PUT to https://example.com:8888/users/22/my_resources/41
  100 + # with a JSON payload e.g. '{"name":"My Resource","user_id":22,"status":"active","id":41}'
  101 + if @api.save(@my_resource)
  102 + flash[:notice] = 'Resource Saved!'
  103 + redirect_to @my_resource
  104 + else
  105 + flash[:error] 'Error saving resource!'
  106 + render :edit and return
  107 + end
  108 + end
  109 +
  110 + def destroy
  111 + # If params[:id] is 41, then this will DELETE https://example.com:8888/users/22/my_resources/41
  112 + if @api.delete(MyResource, :user_id => current_user.id, :id => params[:id])
  113 + flash[:notice] = 'Deleted resource.'
  114 + else
  115 + # If the server returns a 422 and an errors attribute containing a list of error messages,
  116 + # they will automatically be added to the object's errors array.
  117 + flash[:error] = 'Failed to delete resource.'
  118 + end
  119 + redirect_to my_resources_path
  120 + end
  121 +
  122 + # etc.
  123 +
  124 +== Contributing to well_rested
  125 +
  126 +* Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
  127 +* Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
  128 +* Fork the project.
  129 +* Start a feature/bugfix branch.
  130 +* Commit and push until you are happy with your contribution.
  131 +* Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
  132 +* Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
  133 +
  134 +== Copyright
  135 +
  136 +Copyright (c) 2012 Deep Web Technologies, inc. See LICENSE.txt for further details.
  137 +
  138 +Written by Nick Urban.
  139 +
  140 +
49 Rakefile
... ... @@ -0,0 +1,49 @@
  1 +# encoding: utf-8
  2 +
  3 +require 'rubygems'
  4 +require 'bundler'
  5 +begin
  6 + Bundler.setup(:default, :development)
  7 +rescue Bundler::BundlerError => e
  8 + $stderr.puts e.message
  9 + $stderr.puts "Run `bundle install` to install missing gems"
  10 + exit e.status_code
  11 +end
  12 +require 'rake'
  13 +
  14 +require 'jeweler'
  15 +Jeweler::Tasks.new do |gem|
  16 + # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
  17 + gem.name = "well_rested"
  18 + gem.homepage = "https://github.com/DeepWebTechnologies/well_rested"
  19 + gem.license = "MIT"
  20 + gem.summary = %Q{A Resource-based REST Client library (ActiveResource replacement).}
  21 + gem.description = %Q{An Active-Resource replacement that supports camel-cased APIs and HTTP caching, and avoids race conditions when changing authentication information on the fly.}
  22 + gem.email = "nickurban@gmail.com"
  23 + gem.authors = ["Nick Urban"]
  24 + # dependencies defined in Gemfile
  25 +end
  26 +Jeweler::RubygemsDotOrgTasks.new
  27 +
  28 +require 'rspec/core'
  29 +require 'rspec/core/rake_task'
  30 +RSpec::Core::RakeTask.new(:spec) do |spec|
  31 + spec.pattern = FileList['spec/**/*_spec.rb']
  32 +end
  33 +
  34 +RSpec::Core::RakeTask.new(:rcov) do |spec|
  35 + spec.pattern = 'spec/**/*_spec.rb'
  36 + spec.rcov = true
  37 +end
  38 +
  39 +task :default => :spec
  40 +
  41 +require 'rdoc/task'
  42 +Rake::RDocTask.new do |rdoc|
  43 + version = File.exist?('VERSION') ? File.read('VERSION') : ""
  44 +
  45 + rdoc.rdoc_dir = 'rdoc'
  46 + rdoc.title = "well_rested #{version}"
  47 + rdoc.rdoc_files.include('README*')
  48 + rdoc.rdoc_files.include('lib/**/*.rb')
  49 +end
1  VERSION
... ... @@ -0,0 +1 @@
  1 +0.6.0
27 bin/well_rested
... ... @@ -0,0 +1,27 @@
  1 +#!/usr/bin/env ruby -w
  2 +
  3 +# RubyGems sets up the load path, but we're loading from the local directry too.
  4 +$LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
  5 +
  6 +require 'rubygems'
  7 +require 'irb'
  8 +require 'well_rested'
  9 +
  10 +#require File.dirname(__FILE__) + '/../spec/spec_helper'
  11 +
  12 +# Enable request logging.
  13 +require 'restclient/components'
  14 +RestClient.enable Rack::CommonLogger
  15 +
  16 +# Enable request caching via HTTP rules.
  17 +#require 'rack/cache'
  18 +#RestClient.enable Rack::Cache
  19 +
  20 +include WellRested
  21 +
  22 +@api = API.new
  23 +
  24 +puts "\nWelcome to the WellRested command-line application. A default-configure WellRested::API instance is available in @api.\n\n"
  25 +
  26 +IRB.start(__FILE__)
  27 +
29 examples/hn_search.rb
... ... @@ -0,0 +1,29 @@
  1 +# HNSearch
  2 +# API access for news.ycombinator.com
  3 +module HNSearch
  4 + class Base < WellRested::Base
  5 + self.server = 'api.thriftdb.com/api.hnsearch.com'
  6 + end
  7 +
  8 + class User < Base
  9 + self.path = '/users'
  10 +
  11 + def items(api)
  12 + Item.search_items_by_username(api, self.username)
  13 + end
  14 + end
  15 +
  16 + class Item < Base
  17 + self.path = '/items'
  18 +
  19 + def self.search_items_by_username(api, username)
  20 + api.get("#{protocol}://#{server}/items/_search", :filter => { :fields => { :username => username } })
  21 + end
  22 + end
  23 +
  24 + def self.your_author(api)
  25 + api.find(User, :id => 'nick_urban')
  26 + end
  27 +end
  28 +
  29 +
17 lib/generic_utils.rb
... ... @@ -0,0 +1,17 @@
  1 +module GenericUtils
  2 + extend self
  3 +
  4 + # Return a class object given its name.
  5 + def get_class(name)
  6 + Kernel.const_get(name)
  7 + rescue NameError
  8 + nil
  9 + end
  10 +
  11 + # Return whether a class exists for a given name.
  12 + def class_exists?(name)
  13 + get_class(name) != nil
  14 + end
  15 +
  16 +end
  17 +
43 lib/key_transformer.rb
... ... @@ -0,0 +1,43 @@
  1 +
  2 +# This module is used to transform the keys found in resources between Rail's native underscore_case
  3 +# and the camelCase typically found in Java, etc.
  4 +
  5 +module KeyTransformer
  6 + extend self
  7 +
  8 + # Run through a hash or array of hashes and replace the keys with underscored versions.
  9 + # NOTE: Hash values may include simple values, other hashes or arrays, but Arrays may only include hashes.
  10 + # If we had an array of symbols (e.g. for permissions), we'd have to do this slightly differently
  11 + def underscore_keys(hash_or_array)
  12 + key_transform = Proc.new { |key| key.to_s.underscore }
  13 + transform_keys(hash_or_array, key_transform)
  14 + end
  15 +
  16 + def camelize_keys(hash_or_array, upper_or_lower = :lower)
  17 + key_transform = Proc.new { |key| key.to_s.camelize(upper_or_lower) }
  18 + transform_keys(hash_or_array, key_transform)
  19 + end
  20 +
  21 + # Takes a hash or array to process and a key transform (Proc),
  22 + # which should accept a key and return a transformed key.
  23 + # Returns a hash or array, depending on which was passed.
  24 + # NOTE: any objects which respond to 'attributes' (active record, active resource) will be turned into hashes.
  25 + # NOTE: all hashes are returned with indifferent access
  26 + def transform_keys(hash_or_array, key_transform)
  27 + if hash_or_array.is_a? Array
  28 + hash_or_array.map { |obj| transform_keys(obj, key_transform) }
  29 + elsif hash_or_array.is_a? Hash
  30 + new_hash = {}.with_indifferent_access
  31 + hash_or_array.each do |key, val|
  32 + new_hash[key_transform.call(key)] = transform_keys(val, key_transform)
  33 + end
  34 + new_hash
  35 + # Note: this case has been disabled because we now convert objects to hashes before passing them into underscore/camelize_keys.
  36 + # elsif hash_or_array.respond_to? :attributes # if this is an object rather than a hash / array, get the hash from its attributes
  37 + # transform_keys(hash_or_array.attributes, key_transform)
  38 + else
  39 + hash_or_array
  40 + end
  41 + end
  42 +end
  43 +
35 lib/well_rested.rb
... ... @@ -0,0 +1,35 @@
  1 +# encoding: utf-8
  2 +
  3 +# require external dependencies
  4 +require 'active_support/core_ext/hash/indifferent_access'
  5 +require 'active_support/core_ext/hash/reverse_merge'
  6 +
  7 +# require internal general-use libs
  8 +require 'key_transformer'
  9 +require 'generic_utils'
  10 +
  11 +# require internal libs
  12 +require 'well_rested/api'
  13 +require 'well_rested/base'
  14 +require 'well_rested/utils'
  15 +require 'well_rested/json_formatter'
  16 +require 'well_rested/camel_case_formatter'
  17 +
  18 +# Make sure 'bases' singularizes to 'base' instead of 'basis'.
  19 +# Otherwise, we get an error that no class Basis is found in Base.
  20 +ActiveSupport::Inflector.inflections do |inflect|
  21 + inflect.irregular 'base', 'bases'
  22 +end
  23 +
  24 +module WellRested
  25 + def logger
  26 + return Rails.logger if Utils.class_exists? 'Rails'
  27 + return @logger if @logger
  28 +
  29 + require 'logger'
  30 + @logger = Logger.new(STDERR)
  31 + @logger.datetime_format = "%H:%M:%S"
  32 + @logger
  33 + end
  34 +end
  35 +
343 lib/well_rested/api.rb
... ... @@ -0,0 +1,343 @@
  1 +require 'rest-client'
  2 +require 'json'
  3 +require 'active_support/core_ext/object/to_query'
  4 +require 'key_transformer'
  5 +require 'cgi'
  6 +
  7 +require 'well_rested/utils'
  8 +
  9 +module WellRested
  10 + # All REST requests are made through an API object.
  11 + # API objects store cross-resource settings such as user and password (for HTTP basic auth).
  12 + class API
  13 + include WellRested # for logger
  14 + include WellRested::Utils
  15 +
  16 + attr_accessor :user
  17 + attr_accessor :password
  18 + attr_accessor :default_path_parameters
  19 + attr_accessor :client
  20 + attr_reader :last_response
  21 +
  22 + def initialize(path_params = {})
  23 + self.default_path_parameters = path_params.with_indifferent_access
  24 + self.client = RestClient
  25 + end
  26 +
  27 + ##
  28 + # Issue a request of method 'method' (:get, :put, :post, :delete) for the resource identified by 'klass'.
  29 + # If it is a PUT or a POST, the payload_hash should be specified.
  30 + def request(klass, method, path, payload_hash = {}, headers = {})
  31 + auth = (self.user or self.password) ? "#{CGI.escape(user)}:#{CGI.escape(password)}@" : ''
  32 +
  33 + # If path starts with a slash, assume it is relative to the default server.
  34 + if path[0..0] == '/'
  35 + url = "#{klass.protocol}://#{auth}#{klass.server}#{path}"
  36 + else
  37 + # Otherwise, treat it as a fully qualified URL and do not modify it.
  38 + url = path
  39 + end
  40 +
  41 + hash = klass.attribute_formatter.encode(payload_hash)
  42 + payload = klass.body_formatter.encode(hash)
  43 +
  44 + #logger.info "#{method.to_s.upcase} #{url} (#{payload.inspect})"
  45 +
  46 + if [:put, :post].include?(method) # RestClient.put and .post take an extra payload argument.
  47 + client.send(method, url, payload, request_headers.merge(headers)) do |response, request, result, &block|
  48 + @last_response = response
  49 + response.return!(request, result, &block)
  50 + end
  51 + else
  52 + client.send(method, url, request_headers.merge(headers)) do |response, request, result, &block|
  53 + @last_response = response
  54 + response.return!(request, result, &block)
  55 + end
  56 + end
  57 + end
  58 +
  59 + ##
  60 + # GET a single resource.
  61 + # 'klass' is a class that descends from WellRested::Base
  62 + # 'path_params_or_url' is either a url string or a hash of params to substitute into the url pattern specified in klass.path
  63 + # e.g. if klass.path is '/accounts/:account_id/users', then the path_params hash should include 'account_id'
  64 + # 'query_params' is an optional hash of query parameters
  65 + #
  66 + # If path_params includes 'id', it will be added to the end of the path (e.g. /accounts/1/users/1)
  67 + # If path_params_or_url is a hash, query_params will be added on the end (e.g. { :option => 'x' }) produces a url with ?option=x
  68 + # If it is a string, query_params is ignored.
  69 + #
  70 + # Returns an object of class klass representing that resource.
  71 + # If the resource is not found, raises a RestClient::ResourceNotFound exception.
  72 + def find(klass, path_params_or_url = {}, query_params = {})
  73 + if klass.respond_to?(:path_parameters)
  74 + path_params_or_url = klass.path_parameters
  75 + klass = klass.class
  76 + end
  77 +
  78 + url = url_for(klass, path_params_or_url, query_params)
  79 + #logger.info "GET #{url}"
  80 +
  81 + response = client.get(url, request_headers) do |response, request, result, &block|
  82 + @last_response = response
  83 + response.return!(request, result, &block) # default RestClient response handling (raise exceptions on errors, etc.)
  84 + end
  85 +
  86 + raise "Invalid body formatter for #{klass.name}!" if klass.body_formatter.nil? or !klass.body_formatter.respond_to?(:decode)
  87 +
  88 + hash = klass.body_formatter.decode(response)
  89 + decoded_hash = klass.attribute_formatter.nil? ? hash : klass.attribute_formatter.decode(hash)
  90 + klass.new_from_api(decoded_hash)
  91 + end
  92 +
  93 + ##
  94 + # GET a collection of resources.
  95 + # This works the same as find, except it expects and returns an array of resources instead of a single resource.
  96 + def find_many(klass, path_params_or_url = {}, query_params = {})
  97 + url = url_for(klass, path_params_or_url, query_params)
  98 +
  99 + logger.info "GET #{url}"
  100 + response = client.get(url, request_headers) do |response, request, result, &block|
  101 + @last_response = response
  102 + response.return!(request, result, &block)
  103 + end
  104 +
  105 + raise "Invalid body formatter for #{klass.name}!" if klass.body_formatter.nil? or !klass.body_formatter.respond_to?(:decode)
  106 + array = klass.body_formatter.decode(response)
  107 +
  108 + processed_array = klass.attribute_formatter.nil? ? array : klass.attribute_formatter.decode(array)
  109 +
  110 + raise "Response did not parse to an array" unless array.is_a?(Array)
  111 +
  112 + processed_array.map { |e| klass.new_from_api(e) }
  113 + end
  114 +
  115 + ##
  116 + # Create the resource of klass from the given attributes.
  117 + # The class will be instantiated, and its new_from_api and attributes_for_api methods
  118 + # will be used to determine which attributes actually get sent.
  119 + # If url is specified, it overrides the default url.
  120 + def create(klass, attributes = {}, url = nil)
  121 + obj = klass.new(default_path_parameters.merge(attributes))
  122 +
  123 + create_or_update_resource(obj, url)
  124 + end
  125 +
  126 + # Save a resource.
  127 + # Return false if doesn't pass validation.
  128 + # If the update succeeds, return the resource.
  129 + # Otherwise, return a hash containing whatever the server returned (usually includes an array of errors).
  130 + def save(resource, url = nil)
  131 + # convert any hashes that should be objects into objects before saving,
  132 + # so that we can use their attributes_for_api methods in case they need to override what gets sent
  133 + resource.convert_attributes_to_objects
  134 + create_or_update_resource(resource, url)
  135 + end
  136 +
  137 + # DELETE a resource.
  138 + # There are two main ways to call delete.
  139 + # 1) The first argument is a class, and the second argument is an array of path_params that resolve to a path to the resource to delete.
  140 + # (e.g. for klass Post with path '/users/:user_id/posts', :user_id and :id would be required in path_params_or_url to delete /users/x/posts/y)
  141 + # 2) The first argument can be an object to delete. It should include all of the path params in its attributes.
  142 + def delete(klass_or_object, path_params_or_url = {})
  143 + if klass_or_object.respond_to?(:attributes_for_api) # klass_or_object is an object
  144 + klass = klass_or_object.class
  145 + if path_params_or_url.kind_of?(String)
  146 + url = url_for(klass, path_params_or_url)
  147 + else
  148 + params = default_path_parameters.merge(klass_or_object.attributes_for_api)
  149 + url = url_for(klass, params)
  150 + end
  151 + else # klass_or_object is a class
  152 + klass = klass_or_object
  153 + #logger.debug "Calling delete with class #{klass.name} and params: #{path_params.inspect}"
  154 + if path_params_or_url.kind_of?(String)
  155 + url = url_for(klass, path_params_or_url)
  156 + else
  157 + params = default_path_parameters.merge(path_params_or_url)
  158 + url = url_for(klass, params)
  159 + end
  160 + end
  161 +
  162 + #logger.info "DELETE #{url}"
  163 + response = client.delete(url, request_headers) do |response, request, result, &block|
  164 + @last_response = response
  165 + response.return!(request, result, &block)
  166 + end
  167 + end
  168 +
  169 + ##
  170 + # Issue a PUT request to the given url.
  171 + # The post body is specified by 'payload', which can either be a string, an object, a hash, or an array of hashes.
  172 + # If it is not a string, it will be recurisvely converted into JSON using any objects' attributes_for_api methods.
  173 + # TODO: Update this to do something that makes more sense with the formatters.
  174 + # e.g. def put(url, payload, formatter)
  175 + def put(url, payload, options = {})
  176 + default_options = { :json => true }
  177 + opts = default_options.merge(options)
  178 +
  179 + payload = payload.kind_of?(String) ? payload : KeyTransformer.camelize_keys(objects_to_attributes(payload)).to_json
  180 + response = run_update(:put, url, payload)
  181 +
  182 + if opts[:json] and !response.blank?
  183 + objs = JSON.parse(response)
  184 + return KeyTransformer.underscore_keys(objs)
  185 + end
  186 +
  187 + return response
  188 + end
  189 +
  190 + ##
  191 + # Issue a POST request to the given url.
  192 + # The post body is specified by 'payload', which can either be a string, an object, a hash, or an array of hashes.
  193 + # If it is not a string, it will be recurisvely converted into JSON using any objects' attributes_for_api methods.
  194 + # TODO: Same issue as with put, get, etc.
  195 + def post(url, payload, json = true)
  196 + response = client.post(url, payload, request_headers)
  197 + return response unless json
  198 + parsed = JSON.parse(response)
  199 + KeyTransformer.underscore_keys(parsed)
  200 + end
  201 +
  202 + ##
  203 + # Issue a GET request to the given url.
  204 + # If json is passed as true, it will be interpreted as JSON and converted into a hash / array of hashes.
  205 + # Otherwise, the body is returned as a string.
  206 + # TODO: Same issue as with put. def get(url, body_formatter, attribute_formatter) ?
  207 + def get(url, json = true)
  208 + response = client.get(url, request_headers)
  209 + return response unless json
  210 + parsed = JSON.parse(response)
  211 + KeyTransformer.underscore_keys(parsed)
  212 + end
  213 +
  214 + # Generate a full URL for the class klass with the given path_params and query_params
  215 + # In the case of an update, path params will usually be resource.attributes_for_api.
  216 + # In the case of a find(many), query_params might be count, start, etc.
  217 + def url_for(klass, path_params_or_url = {}, query_params = {})
  218 + # CONSIDERATION: Defaults should be settable at the global level on the @api object.
  219 + # They should be overrideable at the class-level (e.g. User) and again at the time of the method call.
  220 + # url_for is currently not overrideable at the class level.
  221 +
  222 + auth = (self.user or self.password) ? "#{CGI.escape(user)}:#{CGI.escape(password)}@" : ''
  223 +
  224 + if path_params_or_url.kind_of?(String)
  225 + # if it starts with a slash, we assume its part of a
  226 + if path_params_or_url[0..0] == '/'
  227 + url = "#{klass.protocol}://#{auth}#{klass.server}#{path_params_or_url}#{klass.extension}"
  228 + else
  229 + # if not, we treat it as fully qualified and do not modify it
  230 + url = path_params_or_url
  231 + end
  232 + else
  233 + path = self.class.fill_path(klass.path, default_path_parameters.merge(path_params_or_url).with_indifferent_access)
  234 + url = "#{klass.protocol}://#{auth}#{klass.server}#{path}"
  235 + end
  236 + url += '?' + klass.attribute_formatter.encode(query_params).to_query unless query_params.empty?
  237 + url
  238 + end
  239 +
  240 + # Convenience method. Also allows request_headers to be can be set on a per-instance basis.
  241 + def request_headers
  242 + self.class.request_headers
  243 + end
  244 +
  245 + # Return the default headers sent with all HTTP requests.
  246 + def self.request_headers
  247 + # Accept necessary for fetching results by result ID, but not in most places.
  248 + { :content_type => 'application/json', :accept => 'application/json' }
  249 + end
  250 +
  251 + # TODO: Move this into a utility module? It can then be called from Base#fill_path or directly if needed.
  252 + def self.fill_path(path_template, params)
  253 + raise "Cannot fill nil path" if path_template.nil?
  254 +
  255 + params = params.with_indifferent_access
  256 +
  257 + # substitute marked params
  258 + path = path_template.gsub(/\:\w+/) do |match|
  259 + sym = match[1..-1].to_sym
  260 + val = params.include?(sym) ? params[sym] : match
  261 + raise ArgumentError.new "Blank parameter #{sym} in path #{path}!" if val.blank?
  262 + val
  263 + end
  264 +
  265 + # Raise an error if we have un-filled parameters
  266 + if path.match(/(\:\w+)/)
  267 + raise ArgumentError.new "Unfilled parameter in path: #{$1} (path: #{path} params: #{params.inspect})"
  268 + end
  269 +
  270 + # ID goes on the end of the resource path but isn't spec'd there
  271 + path += "/#{params[:id]}" unless params[:id].blank?
  272 +
  273 + path
  274 + end
  275 +
  276 +
  277 + protected # internal methods follow
  278 +
  279 + # Create or update a resource.
  280 + # If an ID is set, PUT will be used, else POST.
  281 + # If a 200 is returned, the returned attributes will be loaded into the resource, and the resource returned.
  282 + # Otherwise, the resource will not be modified, and a hash generated from the JSON response will be returned.
  283 + def create_or_update_resource(resource, url = nil)
  284 + return false unless resource.valid?
  285 +
  286 + #logger.info "Creating a #{resource.class}"
  287 +
  288 + path_params = default_path_parameters.merge(resource.path_parameters)
  289 + payload_hash = resource.class.attribute_formatter.encode(resource.attributes_for_api)
  290 + payload = resource.class.body_formatter.encode(payload_hash)
  291 +
  292 + #logger.debug " payload: #{payload.inspect}"
  293 +
  294 + if url.nil?
  295 + url = url_for(resource.class, path_params) # allow default URL to be overriden by url argument
  296 + else
  297 + url = url_for(resource.class, url)
  298 + end
  299 +
  300 + # If ID is set in path parameters, do a PUT. Otherwise, do a POST.
  301 + method = resource.path_parameters[:id].blank? ? :post : :put
  302 +
  303 + response = run_update(method, url, payload)
  304 +
  305 + hash = resource.class.body_formatter.decode(response.body)
  306 + decoded_hash = resource.class.attribute_formatter.decode(hash)
  307 + logger.info "* Errors: #{decoded_hash['errors'].inspect}" if decoded_hash.include?('errors')
  308 +
  309 + if response.code == 200
  310 + # If save succeeds, replace resource's attributes with the ones returned.
  311 + return decoded_hash.map { |hash| resource.class.new_from_api(hash) } if decoded_hash.kind_of?(Array)
  312 + resource.load_from_api(decoded_hash)
  313 + return resource
  314 + elsif decoded_hash.include?('errors')
  315 + resource.handle_errors(decoded_hash['errors'])
  316 + return false
  317 + end
  318 + end
  319 +
  320 + def run_update(method, url, payload)
  321 + logger.debug "#{method.to_s.upcase} #{url} "
  322 + logger.debug " payload: #{payload.inspect}"
  323 +
  324 + http_status = nil
  325 + client.send(method, url, payload, request_headers) do |response, request, result, &block|
  326 + @last_response = response
  327 +
  328 + http_status = response.code
  329 + case response.code
  330 + when 400
  331 + #logger.debug "Got 400: #{response.inspect}"
  332 + response.return!(request, result, &block)
  333 + when 422
  334 + #logger.debug "Got 422: errors should be set"
  335 + response
  336 + else
  337 + # default handling (raise exceptions on errors, etc.)
  338 + response.return!(request, result, &block)
  339 + end
  340 + end
  341 + end
  342 + end
  343 +end
290 lib/well_rested/base.rb
... ... @@ -0,0 +1,290 @@
  1 +# encoding: utf-8
  2 +
  3 +require 'active_model'
  4 +require 'active_support/core_ext/hash/indifferent_access'
  5 +require 'active_support/core_ext/class/attribute'
  6 +require 'active_support/core_ext/string/inflections'
  7 +
  8 +require 'well_rested/json_formatter'
  9 +require 'well_rested/camel_case_formatter'
  10 +require 'well_rested/utils'
  11 +
  12 +module WellRested
  13 + class Base
  14 + include Utils
  15 + include WellRested::Utils
  16 +
  17 + include ActiveModel::Validations
  18 + include ActiveModel::Serializers::JSON
  19 +
  20 + class_attribute :protocol
  21 + class_attribute :server
  22 + class_attribute :path
  23 + class_attribute :schema
  24 +
  25 + class_attribute :body_formatter
  26 + class_attribute :attribute_formatter
  27 + class_attribute :extension # e.g. .json, .xml
  28 +
  29 + # class-level defaults
  30 + self.protocol = 'http'
  31 + # a body formatter must respond to the methods encode(hash_or_array) => string and decode(string) => hash_or_array
  32 + self.body_formatter = JSONFormatter.new
  33 + self.extension = ''
  34 + # an attribute formatter must respond to encode(attribute_name_string) => string and decode(attribute_name_string) => string
  35 + self.attribute_formatter = CamelCaseFormatter.new
  36 +
  37 + attr_reader :attributes
  38 + attr_accessor :new_record
  39 +
  40 + ##
  41 + # Define the schema for this resource.
  42 + #
  43 + # Either takes an array, or a list of arguments which we treat as an array.
  44 + # Each element of the array should be either a symbol or a hash.
  45 + # If it's a symbol, we create an attribute using the symol as the name and with a null default value.
  46 + # If it's a hash, we use the keys as attribute names.
  47 + # - Any values that are hashes, we use to specify further options (currently, the only option is :default).
  48 + # - Any value that is not a hash is treated as a default.
  49 + # e.g.
  50 + # define_schema :x, :y, :z # x, y, and z all default to nil
  51 + # define_schema :id, :name => 'John' # id defaults to nil, name defaults to 'John'
  52 + # define_schema :id, :name => { :default => 'John' } # same as above
  53 + def self.define_schema(*args)
  54 + return schema if args.empty?
  55 +
  56 + attrs = args.first.is_a?(Array) ? args.first : args
  57 + self.schema = {}.with_indifferent_access
  58 + attrs.each do |attr|
  59 + if attr.is_a?(Symbol)
  60 + self.schema[attr] = { :default => nil }
  61 + elsif attr.is_a?(Hash)
  62 + attr.each do |k,v|
  63 + if v.is_a?(Hash)
  64 + self.schema[k] = v
  65 + else
  66 + self.schema[k] = { :default => v }
  67 + end
  68 + end
  69 + end
  70 + end
  71 +
  72 +=begin
  73 + # Possible alternative to using method_missing:
  74 + # define getter/setter methods for attributes.
  75 + @attributes.keys.each do |attr_name|
  76 + define_method(attr_name) { @attributes[attr_name] }
  77 + define_method("#{attr_name}=") { |val| @attributes[attr_name] = val }
  78 + end
  79 +=end
  80 +
  81 + self.schema
  82 + end
  83 +
  84 + def initialize(attrs = {})
  85 + raise "Attrs must be hash" unless attrs.is_a? Hash
  86 +
  87 + self.load(attrs, false)
  88 + end
  89 +
  90 + # Define an actual method for ID. This is important in Ruby 1.8 where the object_id method is also aliased to id.
  91 + def id
  92 + attributes[:id]
  93 + end
  94 +
  95 + # borrowed from http://stackoverflow.com/questions/2393697/look-up-all-descendants-of-a-class-in-ruby
  96 + # Show all subclasses
  97 + def self.descendants
  98 + ObjectSpace.each_object(::Class).select { |klass| klass < self }
  99 + end
  100 +
  101 + # Create a map of all descendants of Base to lookup classes from names when converting hashes to objects.
  102 + def self.descendant_map
  103 + return @descendant_map if @descendant_map
  104 + @descendant_map = {}.with_indifferent_access
  105 + self.descendants.each do |des|
  106 + unless des.name.blank?
  107 + sep_index = des.name.rindex('::')
  108 + short_name = sep_index ? des.name[sep_index+2..-1] : des.name
  109 + @descendant_map[short_name] = des
  110 + end
  111 + end
  112 + @descendant_map
  113 + end
  114 +
  115 + # Convenience method for creating an object and calling load_from_api
  116 + # The API should call this method when creating representations of objects that are already persisted.
  117 + #
  118 + # By default, attributes loaded from the API have new_record set to true. This has implications for Rails form handling.
  119 + # (Rails uses POST for records that it thinks are new, but PUT for records that it thinks are already persisted.)
  120 + def self.new_from_api(attrs)
  121 + obj = self.new
  122 + obj.load_from_api(attrs)
  123 + return obj
  124 + end
  125 +
  126 + # Load this resource from attributes. If these attributes were received from the API, true should be passed for from_api.
  127 + # This will ensure any object-specific loading behavior is respected.
  128 + # example:
  129 + # res = Resource.new
  130 + # res.load(:name => 'New')
  131 + def load(attrs_to_load, from_api = false)
  132 + raise "Attrs is not a hash: #{attrs_to_load.inspect}" unless attrs_to_load.kind_of? Hash
  133 +
  134 + #puts "*** Warning: loading a resource without a schema (#{self.class})!" if schema.nil?
  135 + #raise "Tried to load attributes for a resource with no schema (#{self.class})!" if schema.nil?
  136 +
  137 + # By default we mark a record as new if it doesn't come from the API and it doesn't have an ID attribute.
  138 + self.new_record = !from_api and !attrs_to_load.include?('id')
  139 +
  140 + new_attrs = {}.with_indifferent_access
  141 +
  142 + # Take default values from schema, but allow arbitrary args to be loaded.
  143 + # We will address the security issue by filtering in attributes_for_api.
  144 + schema.each { |key, opts| new_attrs[key] = opts[:default] } unless schema.blank?
  145 + new_attrs.merge!(attrs_to_load)
  146 +
  147 + @attributes = self.class.hash_to_objects(new_attrs, from_api).with_indifferent_access
  148 +
  149 + return self
  150 + end
  151 +
  152 + # Load attributes from the API.
  153 + # This method exists to be overridden so that attributes created manually can be handled differently from those loaded from the API.
  154 + def load_from_api(attrs)
  155 + load(attrs, true)
  156 + end
  157 +
  158 + # Convert attribute hashes that represent objects into objects
  159 + def convert_attributes_to_objects
  160 + self.class.hash_to_objects(attributes, self.class)
  161 + end
  162 +
  163 + # This method is called by API when a hash including 'errors' is returned along with an HTTP error code.
  164 + def handle_errors(received_errors)
  165 + received_errors.each do |err|
  166 + self.errors.add :base, err
  167 + end
  168 + end
  169 +
  170 + # When we are loading a resource from an API call, we will use this method to instantiate classes based on attribute names.
  171 + def self.find_resource_class(class_name)
  172 + klass = Utils.get_class(class_name)
  173 + #puts "**** descendant map: #{Base.descendant_map.inspect}"
  174 + return klass if klass.respond_to?(:new_from_api)
  175 + Base.descendant_map[class_name]
  176 + end
  177 +
  178 + # Convert a hash received from the API into an object or array of objects.
  179 + # e.g. Base.hash_to_objects({'base' => {'name' => 'Test'} }) => {"base"=>#<WellRested::Base:0x10244de70 @attributes={"name"=>"Test"}}
  180 + def self.hash_to_objects(hash, from_api = false)
  181 + hash.each do |k,v|
  182 + if v.kind_of?(Hash)
  183 + class_name = k.camelize
  184 + klass = self.find_resource_class(class_name)
  185 + if klass
  186 + hash[k] = from_api ? klass.new_from_api(v) : klass.new(v)
  187 + end
  188 + elsif v.kind_of?(Array)
  189 + class_name = k.to_s.singularize.camelize
  190 + #puts "**** class_name=#{class_name}"
  191 + klass = find_resource_class(class_name)
  192 + if klass
  193 + #puts "**** class exists, instantiation"
  194 + hash[k] = v.map do |o|
  195 + if o.kind_of?(Hash)
  196 + from_api ? klass.new_from_api(o) : klass.new(o)
  197 + else
  198 + o
  199 + end
  200 + end
  201 + else
  202 + #puts "**** class does not exist"
  203 + end
  204 + end
  205 + end
  206 + hash
  207 + end
  208 +
  209 + def self.fill_path(params)
  210 + API.fill_path(self.path, params)
  211 + end
  212 +
  213 + # Return the attributes that we want to send to the server when this resource is saved.
  214 + # If a schema is defined, only return elements defined in the schema.
  215 + # Override this for special attribute-handling.
  216 + def attributes_for_api
  217 + # by default, filter out nil elements
  218 + hash = objects_to_attributes(@attributes.reject { |k,v| v.nil? }.with_indifferent_access)
  219 + # and anything not included in the schema
  220 + hash.reject! { |k,v| !schema.include?(k) } unless schema.nil?
  221 + hash
  222 + end
  223 +
  224 + # API should use these to generate the path.
  225 + # Override this to control how path variables get inserted.
  226 + def path_parameters
  227 + objects_to_attributes(@attributes.reject { |k,v| v.nil? }.with_indifferent_access)
  228 + end
  229 +
  230 + # Run active_model validations on @attributes hash.
  231 + def read_attribute_for_validation(key)
  232 + @attributes[key]
  233 + end
  234 +
  235 + # Return a string form of this object for rails to use in routes.
  236 + def to_param
  237 + self.id.nil? ? nil : self.id.to_s
  238 + end
  239 +
  240 + # Return a key for rails to use for... not sure exaclty what.
  241 + # Should be an array, or nil.
  242 + def to_key
  243 + self.id.nil? ? nil : [self.id]
  244 + end
  245 +
  246 + # The following 3 methods were copied from active_record/persistence.rb
  247 + # Returns true if this object hasn't been saved yet -- that is, a record
  248 + # for the object doesn't exist in the data store yet; otherwise, returns false.
  249 + def new_record?
  250 + self.new_record
  251 + end
  252 +
  253 + # Alias of new_record? Apparently used by Rails sometimes.
  254 + def new?
  255 + self.new_record
  256 + end
  257 +
  258 + # Returns true if this object has been destroyed, otherwise returns false.
  259 + #def destroyed?
  260 + # @destroyed
  261 + #end
  262 +
  263 + # Returns if the record is persisted, i.e. it's not a new record and it was
  264 + # not destroyed.
  265 + def persisted?
  266 + # !(new_record? || destroyed?)
  267 + !new_record?
  268 + end
  269 +
  270 + # Equality is defined as having the same attributes.
  271 + def ==(other)
  272 + other.respond_to?(:attributes) ? (self.attributes == other.attributes) : false
  273 + end
  274 +
  275 + # Respond to getter and setter methods for attributes.
  276 + def method_missing(method_sym, *args, &block)
  277 + method = method_sym.to_s
  278 + # Is this an attribute getter?
  279 + if args.empty? and attributes.include?(method)
  280 + attributes[method]
  281 + # Is it an attribute setter?
  282 + elsif args.length == 1 and method[method.length-1..method.length-1] == '=' and attributes.include?(attr_name = method[0..method.length-2])
  283 + attributes[attr_name] = args.first
  284 + else
  285 + super
  286 + end
  287 + end
  288 + end
  289 +end
  290 +
15 lib/well_rested/camel_case_formatter.rb
... ... @@ -0,0 +1,15 @@
  1 +module WellRested
  2 + class CamelCaseFormatter
  3 + def initialize(lower = true)
  4 + raise "Upper case camelizing not supported yet" unless lower # TODO: Support upper-camel-casing
  5 + end
  6 +
  7 + def encode(hash)
  8 + KeyTransformer.camelize_keys(hash)
  9 + end
  10 +
  11 + def decode(hash)
  12 + KeyTransformer.underscore_keys(hash)
  13 + end
  14 + end
  15 +end
12 lib/well_rested/json_formatter.rb