├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── api_smith.gemspec ├── lib ├── .yardopts ├── api_smith.rb └── api_smith │ ├── base.rb │ ├── client.rb │ ├── smash.rb │ ├── version.rb │ └── web_mock_extensions.rb └── spec ├── api_smith ├── base_spec.rb ├── client_spec.rb └── smash_spec.rb ├── spec_helper.rb └── support └── fake_endpoints.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | examples/keys.rb 3 | pkg 4 | rdoc 5 | .yardoc 6 | doc 7 | coverage -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | rvm: 4 | - 1.8.7 5 | - 1.9.3 6 | - 2.0.0 7 | env: 8 | - HASHIE_VERSION="" 9 | - RAILS_VERSION="~>2.0" 10 | - RAILS_VERSION="~>1.0" 11 | notifications: 12 | email: 13 | - sutto@sutto.net 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org/" 2 | 3 | gemspec 4 | 5 | gem 'hashie', ENV.fetch('HASHIE_VERSION', '~> 1.0') 6 | gem 'json' 7 | 8 | group :development do 9 | gem 'rake' 10 | gem 'awesome_print' 11 | end 12 | 13 | group :test do 14 | gem 'rspec' 15 | gem 'sham_rack' 16 | gem 'sinatra' # for sham_rack 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | api_smith (1.2.0) 5 | hashie (>= 1.0, < 3.0) 6 | httparty 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | awesome_print (0.4.0) 12 | diff-lcs (1.1.2) 13 | hashie (2.0.5) 14 | httparty (0.11.0) 15 | multi_json (~> 1.0) 16 | multi_xml (>= 0.5.2) 17 | json (1.5.3) 18 | multi_json (1.7.7) 19 | multi_xml (0.5.4) 20 | rack (1.3.0) 21 | rake (10.1.0) 22 | rr (1.0.2) 23 | rspec (2.6.0) 24 | rspec-core (~> 2.6.0) 25 | rspec-expectations (~> 2.6.0) 26 | rspec-mocks (~> 2.6.0) 27 | rspec-core (2.6.4) 28 | rspec-expectations (2.6.0) 29 | diff-lcs (~> 1.1.2) 30 | rspec-mocks (2.6.0) 31 | sham_rack (1.3.3) 32 | rack 33 | sinatra (1.2.6) 34 | rack (~> 1.1) 35 | tilt (>= 1.2.2, < 2.0) 36 | tilt (1.3.2) 37 | 38 | PLATFORMS 39 | ruby 40 | 41 | DEPENDENCIES 42 | api_smith! 43 | awesome_print 44 | hashie (~> 2.0) 45 | json 46 | rake 47 | rr 48 | rspec 49 | sham_rack 50 | sinatra 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Filter Squad 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Smith 2 | 3 | API Smith makes building clients for HTTP-based APIs easy. 4 | 5 | By building on top of HTTParty and Hashie, API Smith provides tools tailor-made for making API 6 | clients for structured data - That is, responses with a well defined design. These tools are 7 | made possible by two parts - APISmith::Smash, a smarter dash / hash-like object which lets 8 | you have simple declarative data structures and APISmith::Client, a layer on top of HTTParty 9 | that makes dealing with APIs even simpler and more consistent. 10 | 11 | ## APISmith::Smash - A Smarter Hash 12 | 13 | API Smith's Smash class is a 'smarter' (or, alternatively, structured) hash. Built on top of 14 | `Hashie::Dash`, `APISmith::Smash` adds several features that make it useful for making objects 15 | that represent an external api response. On top of [the base Hashie::Dash feature set](https://github.com/intridea/hashie/blob/master/lib/hashie/dash.rb), 16 | APISmith::Smash adds: 17 | 18 | ### Configuration of alternative names for fields 19 | 20 | A feature useful for dealing with apis where they may have a `fullName` field in their api response 21 | but you want to use `full_name`. Configurable by a simple `:from` option on property declarations. 22 | 23 | This importantly lets you deal with one format internally whilst automatically taking care of normalising 24 | external data sources. More importantly, it also provides simple methods you can override to handle 25 | a wide range of external schemes (e.g. always underscoring the field name). 26 | 27 | ### Configurable transformers 28 | 29 | Essentially, any object (e.g. a lambda, a class or something else) that responds to `#call` can be used 30 | to transform incoming data into a useable format. More importantly, your smash classes will also respond 31 | to `#call` meaning they can intelligently be used as transformers for other classes, making complex / nested 32 | objects simple and declarative. 33 | 34 | On top of this, APISmith::Client also uses the same `#call`-able convention, making it even easier to 35 | use a consistent scheme for converting data across the application. 36 | 37 | Using it on a property is as simple as passing a `:transformer` option with the `#call`-able object 38 | as the value. 39 | 40 | ### A well defined (and documented) api 41 | 42 | Making it possible for you to hook in at multiple stages to further specialise your Smash objects for 43 | specific API use cases. 44 | 45 | ## APISmith::Client - Making API Clients Sexy 46 | 47 | APISmith::Client is a collection of tools built on top of [HTTParty](https://github.com/jnunemaker/httparty) that 48 | adds tools to make writing API clients simpler. Namely, it adds: 49 | 50 | ### Configurable Endpoints 51 | 52 | Put simply, even though HTTParty adds the `base_uri` class option, there are times where 53 | we want to be able to create a class of base logic but still vary a common part of the URI. For 54 | this, APISmith::Client supports endpoints. Essentially, a path part that is prefixed to all 55 | paths passed to `get`, `post`, `put` and `delete`. 56 | 57 | Using this in your client is as simple as calling the `endpoint` class method - e.g.: 58 | 59 | 60 | class MyAPIClient 61 | include APISmith::Client 62 | base_uri "http://example.com/" 63 | endpoint "api/v1" 64 | end 65 | 66 | Then, calling `MyAPIClient.new.get('/blah')` will hit up the url at `http://example.com/api/v1/blah`. 67 | 68 | This is most importantly useful when dealing with restful - `base_uri` can point to the site root and 69 | then you can subclass your base client class and set the endpoint for each resource. More importantly, 70 | because you can override `APISmith::Client::InstanceMethods#endpoint` method, you can also make 71 | your endpoint take into account parent resource ids and the like. 72 | 73 | ### Hierarchal Request, Body and Query String options 74 | 75 | Out of the box, we give you support for configuring options on three levels: 76 | 77 | * The class - e.g. parameters to set the response type to JSON 78 | * The instance - e.g. an api key parameter that all instances require 79 | * The request - e.g. a parameter required for that specific API call. 80 | 81 | Out of the box, it transparently supports using these options for both the request 82 | body, the request query string and the request options in general (for HTTParty). 83 | 84 | For each of these types (`query`, `body` and `request`), it's easy to hook in to them 85 | and to set them. For class-level options, simply define a `base_#{type}_options` method, 86 | e.g: 87 | 88 | def base_query_options 89 | {:format => 'json'} 90 | end 91 | 92 | For per-instance options, simply use the `add_#{type}_options!` method (which takes 93 | a hash of options). For example, see `APISmith::Client::InstanceMethods#add_query_options!`. 94 | 95 | Finally, you can use the `:extra_#{type}` options (e.g. `:extra_query`), for example: 96 | 97 | get '/', :extra_query => {:before_timestamp => 2.weeks.ago.to_s} 98 | 99 | ### Response Unpacking 100 | 101 | Via the `:response_container` argument to the `get`, `post`, `put` and `delete` methods, API Smith 102 | supports automatically taking the parsed responses and getting just the bit you care about. 103 | 104 | In cases where the API consistently packages the data in a simple manner, it's also possible to 105 | override the default response container, making it somewhat simple to automate the whole unpacking 106 | process. As an example, say your api returns: 107 | 108 | { 109 | "data": { 110 | "values": "some-other-data-here" 111 | } 112 | } 113 | 114 | Via the `:response_container` option, when your transformer is called, it wont have to deal with the data and values keys, 115 | You will only need to deal with the contents directly, in this case - `"some-other-data-here"`, simply by passing: 116 | 117 | :response_container => %w(data values) 118 | 119 | ### Simple Response Transformations 120 | 121 | The most important aspect of APISmith::Client comes down to it's support of the `:transform` option. Much like 122 | the `:transformer` option on Smash properties, Adding `:transform` with a `#call`-able object to your call to 123 | `get`, `post`, `put` or `delete` will automatically invoke your transformer with the unpacked response. 124 | 125 | As an added bonus, because APISmith::Smash defines a `call` class method, you can then simply pass one 126 | of your Smash subclasses to the transform option and API Smith will intelligently unpack your data into the 127 | objects you care about. 128 | 129 | ## Contributors 130 | 131 | API Smith was written by [Darcy Laycock](https://github.com/sutto), and [Steve Webb](https://github.com/swebb) 132 | from [The Frontier Group](https://github.com/thefrontiergroup), as part of a bigger project with [Filter Squad](https://github.com/filtersquad). 133 | 134 | * Thanks to [Pranas Kiziela](https://github.com/Pranas) for misc. compatibility related contributions. 135 | * Thanks to [Calinoiu Alexandru Nicolae](https://github.com/balauru) for ensuring it's updated to Hashie 2.0 compatibility. 136 | 137 | ## Contributing 138 | 139 | We encourage all community contributions. Keeping this in mind, please follow these general guidelines when contributing: 140 | 141 | * Fork the project 142 | * Create a topic branch for what you’re working on (git checkout -b awesome_feature) 143 | * Commit away, push that up (git push your\_remote awesome\_feature) 144 | * Create a new GitHub Issue with the commit, asking for review. Alternatively, send a pull request with details of what you added. 145 | * Once it’s accepted, if you want access to the core repository feel free to ask! Otherwise, you can continue to hack away in your own fork. 146 | 147 | Other than that, our guidelines very closely match the GemCutter guidelines [here](http://wiki.github.com/qrush/gemcutter/contribution-guidelines). 148 | 149 | (Thanks to [GemCutter](http://wiki.github.com/qrush/gemcutter/) for the contribution guide) 150 | 151 | ## License 152 | 153 | API Smith is released under the MIT License (see the [license file](LICENSE)) and is 154 | copyright Filter Squad, 2011. 155 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rspec/core' 3 | require 'rspec/core/rake_task' 4 | require 'bundler/gem_tasks' 5 | 6 | 7 | task :default => :spec 8 | 9 | desc "Run all specs in spec directory (excluding plugin specs)" 10 | RSpec::Core::RakeTask.new(:spec) 11 | -------------------------------------------------------------------------------- /api_smith.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib/', __FILE__) 3 | $:.unshift lib unless $:.include?(lib) 4 | require 'api_smith/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "api_smith" 8 | s.version = APISmith::VERSION.dup 9 | s.platform = Gem::Platform::RUBY 10 | s.authors = ["Darcy Laycock", "Steve Webb"] 11 | s.email = ["sutto@thefrontiergroup.com.au"] 12 | s.homepage = "http://github.com/thefrontiergroup" 13 | s.summary = "A simple layer on top of HTTParty for building API's" 14 | s.description = "APISmith provides tools to make working with structured HTTP-based apis even easier." 15 | s.required_rubygems_version = ">= 1.3.6" 16 | 17 | s.add_dependency 'httparty' 18 | s.add_dependency 'hashie', '>= 1.0', '< 3.0' 19 | 20 | s.add_development_dependency 'rr' 21 | s.add_development_dependency 'rspec', '~> 2.0' 22 | 23 | s.files = Dir.glob("{lib}/**/*") 24 | s.require_path = 'lib' 25 | end 26 | -------------------------------------------------------------------------------- /lib/.yardopts: -------------------------------------------------------------------------------- 1 | --no-private --protected api_smith/**/*.rb 2 | -------------------------------------------------------------------------------- /lib/api_smith.rb: -------------------------------------------------------------------------------- 1 | # Provides a simple set of tools built on top of Hashie and HTTParty to make 2 | # it easier to build clients for different apis. 3 | 4 | # @see APISmith::Smash 5 | # @see APISmith::Base 6 | # 7 | # @author Darcy Laycock 8 | # @author Steve Webb 9 | module APISmith 10 | 11 | require 'api_smith/version' 12 | require 'api_smith/smash' 13 | require 'api_smith/client' 14 | require 'api_smith/base' 15 | 16 | end 17 | -------------------------------------------------------------------------------- /lib/api_smith/base.rb: -------------------------------------------------------------------------------- 1 | require 'api_smith/client' 2 | 3 | module APISmith 4 | # A base class for building api clients (with a specified endpoint and general 5 | # shared options) on top of HTTParty, including response unpacking and transformation. 6 | # 7 | # Used to convert APISmith::Client to a class (versus a mixin), making it useable 8 | # in certain other situations where it isn't necessarily useful otherwise. 9 | # 10 | # @author Darcy Laycock 11 | # @author Steve Webb 12 | class Base 13 | include APISmith::Client 14 | 15 | def initialize(*) 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/api_smith/client.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | module APISmith 4 | 5 | # A mixin providing the base set of functionality for building API clients. 6 | # 7 | # @see InstanceMethods 8 | # @see ClassMethods 9 | # @see HTTParty 10 | # 11 | # @author Darcy Laycock 12 | # @author Steve Webb 13 | module Client 14 | 15 | # Hooks into the mixin process to add HTTParty and the two APISmith::Client 16 | # components to the given parent automatically. 17 | # @param [Class] parent the object this is being mixed into 18 | def self.included(parent) 19 | parent.class_eval do 20 | include HTTParty 21 | include InstanceMethods 22 | extend ClassMethods 23 | end 24 | end 25 | 26 | # The most important part of the client functionality - namely, provides a set of tools 27 | # and methods that make it possible to build API clients. This is where the bulk of the 28 | # client work takes place. 29 | module InstanceMethods 30 | 31 | # Given a path relative to the endpoint (or `/`), will perform a GET request. 32 | # 33 | # If provided, will use a `:transformer` to convert the resultant data into 34 | # a useable object. Also, in the case an object is nested, allows you to 35 | # traverse an object. 36 | # 37 | # @param [String] path the relative path to the object, pre-normalisation 38 | # @param [Hash] options the raw options to be passed to ``#request!`` 39 | # @see #request! 40 | def get(path, options = {}) 41 | request! :get, path, options, :query 42 | end 43 | 44 | # Given a path relative to the endpoint (or `/`), will perform a POST request. 45 | # 46 | # If provided, will use a `:transformer` to convert the resultant data into 47 | # a useable object. Also, in the case an object is nested, allows you to 48 | # traverse an object. 49 | # 50 | # @param [String] path the relative path to the object, pre-normalisation 51 | # @param [Hash] options the raw options to be passed to ``#request!`` 52 | # @see #request! 53 | def post(path, options = {}) 54 | request! :post, path, options, :query, :body 55 | end 56 | 57 | # Given a path relative to the endpoint (or `/`), will perform a PUT request. 58 | # 59 | # If provided, will use a `:transformer` to convert the resultant data into 60 | # a useable object. Also, in the case an object is nested, allows you to 61 | # traverse an object. 62 | # 63 | # @param [String] path the relative path to the object, pre-normalisation 64 | # @param [Hash] options the raw options to be passed to ``#request!`` 65 | # @see #request! 66 | def put(path, options = {}) 67 | request! :put, path, options, :query, :body 68 | end 69 | 70 | # Given a path relative to the endpoint (or `/`), will perform a DELETE request. 71 | # 72 | # If provided, will use a `:transformer` to convert the resultant data into 73 | # a useable object. Also, in the case an object is nested, allows you to 74 | # traverse an object. 75 | # 76 | # @param [String] path the relative path to the object, pre-normalisation 77 | # @param [Hash] options the raw options to be passed to ``#request!`` 78 | # @see #request! 79 | def delete(path, options = {}) 80 | request! :delete, path, options, :query 81 | end 82 | 83 | # Performs a HTTP request using HTTParty, using a set of expanded options built up by the current client. 84 | # 85 | # @param [:get, :post, :put, :delete] method the http request method to use 86 | # @param [String] path the request path, relative to either the endpoint or /. 87 | # @param [Hash{Symbol => Object}] options the options for the given request. 88 | # @param [Array] param_types the given parameter types (e.g. :body, :query) to add to the request 89 | # 90 | # @option options [true,false] :skip_endpoint If true, don't expand the given path before processing. 91 | # @option options [Array] :response_container If present, it will traverse an array of objects to unpack 92 | # @option options [Hash] :extra_request Extra raw, request options to pass in to the request 93 | # @option options [Hash] :extra_body Any parameters to add to the request body 94 | # @option options [Hash] :extra_query Any parameters to add to the request query string 95 | # @option options [#call] :transform An object, invoked via #call, that takes the response and 96 | # transformers it into a useable form. 97 | # 98 | # @return the response, defaults to the raw HTTParty::Response 99 | # 100 | # @see #path_for 101 | # @see #extract_response 102 | # @see #transform_response 103 | def request!(method, path, options, *param_types) 104 | # Merge in the default request options, e.g. those to be passed to HTTParty raw 105 | request_options = merged_options_for(:request, options) 106 | # Exapdn the path out into a full version when the endpoint is present. 107 | full_path = request_options[:skip_endpoint] ? path : path_for(path) 108 | # For each of the given param_types (e.g. :query, :body) will automatically 109 | # merge in the options for the current request. 110 | param_types.each do |type| 111 | request_options[type] = merged_options_for(type, options) 112 | end 113 | # Finally, use HTTParty to get the response 114 | response = instrument_request method, full_path, options do 115 | self.class.send method, full_path, request_options 116 | end 117 | # Pre-process the response to check for errors. 118 | check_response_errors response 119 | # Unpack the response using the :response_container option 120 | inner_response = extract_response path, response, options 121 | # Finally, apply any transformations 122 | transform_response inner_response, options 123 | end 124 | 125 | private 126 | 127 | # Provides a hook developers can utilitise to implement logging / instrumentation. 128 | # @param [Symbol] method the HTTP method to use 129 | # @param [String] full_path the full path being hit 130 | # @param [Hash] options an options being passed to the request 131 | # @param [#call] the block to invoke it with. 132 | def instrument_request(method, full_path, options) 133 | yield if block_given? 134 | end 135 | 136 | # Provides a hook to handle checking errors on API responses. This is called 137 | # post-fetch and pre-unpacking / transformation. It is passed the apis response 138 | # post-decoding (meaning JSON etc have been parsed into normal ruby objects). 139 | # @param [Object] response the raw decoded api response 140 | def check_response_errors(response) 141 | end 142 | 143 | # Merges in options of a given type into the base options, taking into account 144 | # 145 | # * Shared options (e.g. #base_query_options) 146 | # * Instance-level options (e.g. #query_options) 147 | # * Call-level options (e.g. the :extra_query option) 148 | # 149 | # @param [Symbol] type the type of options, one of :body, :query or :request 150 | # @param [Hash] options the hash to check for the `:extra_{type}` option. 151 | # @return [Hash] a hash of the merged options. 152 | def merged_options_for(type, options) 153 | base = send :"base_#{type}_options" 154 | base.merge!(send(:"#{type}_options") || {}) 155 | base.merge! options.fetch(:"extra_#{type}", {}) 156 | base 157 | end 158 | 159 | # The base set of body parameters, common to all instances of the client. 160 | # Ideally, in your client you'd override this to return other required 161 | # parameters that are the same across all client subclasses e.g. the format. 162 | # 163 | # These will automatically be included in POST and PUT requests but not 164 | # GET or DELETE requests. 165 | # 166 | # @example 167 | # def base_body_options 168 | # {:format => 'json'} 169 | # end 170 | # 171 | def base_body_options 172 | {} 173 | end 174 | 175 | # The base set of query parameters, common to all instances of the client. 176 | # Ideally, in your client you'd override this to return other required 177 | # parameters that are the same across all client subclasses e.g. the format. 178 | # 179 | # These will automatically be included in all requests as part of the query 180 | # string. 181 | # 182 | # @example 183 | # def base_query_options 184 | # {:format => 'json'} 185 | # end 186 | # 187 | def base_query_options 188 | {} 189 | end 190 | 191 | # The base set of request options as accepted by HTTParty. These can be used to 192 | # setup things like the normaliser HTTParty will use for parameters. 193 | def base_request_options 194 | {} 195 | end 196 | 197 | # Per-instance configurable query parameters. 198 | # @return [Hash] the instance-specific query parameters. 199 | # @see #add_query_options! 200 | def query_options 201 | @query_options ||= {} 202 | end 203 | 204 | # Per-instance configurable body parameters. 205 | # @return [Hash] the instance-specific body parameters. 206 | # @see #add_body_options! 207 | def body_options 208 | @body_options ||= {} 209 | end 210 | 211 | # Per-instance configurable request options. 212 | # @return [Hash] the instance-specific request options. 213 | # @see #add_request_options! 214 | def request_options 215 | @request_options ||= {} 216 | end 217 | 218 | # Merges in a hash of extra query parameters to the given request, applying 219 | # them for every request that has query string parameters. Typically 220 | # called from inside #initialize. 221 | # @param [Hash{Symbol => Object}] value a hash of options to add recently 222 | def add_query_options!(value) 223 | query_options.merge! value 224 | end 225 | 226 | # Merges in a hash of extra body parameters to the given request, applying 227 | # them for every request that has body parameters. Typically called from 228 | # inside #initialize. 229 | # @param [Hash{Symbol => Object}] value a hash of options to add recently 230 | def add_body_options!(value) 231 | body_options.merge! value 232 | end 233 | 234 | # Merges in a hash of request options to the given request, applying 235 | # them for every request that has query string parameters. Typically called 236 | # from inside #initialize. 237 | # @param [Hash{Symbol => Object}] value a hash of options to add recently 238 | def add_request_options!(value) 239 | request_options.merge! value 240 | end 241 | 242 | # Given a path, expands it relative to the / and the defined endpoint 243 | # for this class. 244 | # @param [String] path the current, unexpanded path for the api call. 245 | # @example With an endpoint of v1 246 | # path_for('test') # => "/v1/test" 247 | def path_for(path) 248 | File.join(*['', endpoint, path].compact) 249 | end 250 | 251 | # Given a path, response and options, will walk the response object 252 | # (typically hashes and arrays) to unpack / extract the users response. 253 | # 254 | # Note that the response container will be found either via a :response_container 255 | # option or, if not specified at all, the result of #default_response_container to 256 | # 'get' the part of the response that the user cares about. 257 | # 258 | # @param [String] path the path used for the request# 259 | # @param [Hash, Array] response the object returned from the api call 260 | # @param [Hash] options the options passed to the api call 261 | # @option options [Array] :response_container the container to unpack 262 | # from, e.g. ["a", 1, "b"], %w(a 2 3) or something else. 263 | def extract_response(path, response, options) 264 | # First, get the response container options 265 | response_container = options.fetch(:response_container) do 266 | default_response_container(path, options) 267 | end 268 | # And then unpack then 269 | if response_container 270 | response_keys = Array(response_container) 271 | response = response_keys.inject(response) do |r, key| 272 | r.respond_to?(:[]) ? r[key] : r 273 | end 274 | end 275 | response 276 | end 277 | 278 | # Takes a response and, if present, uses the :transform option to convert 279 | # it into a useable object. 280 | # @param [Hash, Array] response the object returned from the api call 281 | # @param [Hash] options the options passed to the api call 282 | # @option options [#call] :transform If present, passed the unpack response. 283 | # @option option [#call] :transformer see the :transform option 284 | # @return [Object] the transformed response, or the response itself if no :transform 285 | # option is passed. 286 | def transform_response(response, options) 287 | transformer = options[:transform] || options[:transformer] 288 | if transformer 289 | transformer.call response 290 | else 291 | response 292 | end 293 | end 294 | 295 | # Returns the current api endpoint, if present. 296 | # @return [nil, String] the current endpoint 297 | def endpoint 298 | nil 299 | end 300 | 301 | # A hook method to define the default response container for a given 302 | # path and set of options to an API call. Intended to be used inside 303 | # subclasses to make it possible to define a standardised way to unpack 304 | # responses without having to pass a `:response_container` option. 305 | # @param [String] path the current path to the request 306 | # @param [Hash] options the set of options passed to #request! 307 | # @return [nil, Array] the array of indices (either hash / array indices) 308 | # to unpack the response via. 309 | def default_response_container(path, options) 310 | nil 311 | end 312 | 313 | end 314 | 315 | # Class level methods to let you configure your api client. 316 | module ClassMethods 317 | 318 | # When present, lets you specify the api for the given client. 319 | # @param [String, nil] value the endpoint to use. 320 | # @example Setting a string endpoint 321 | # endpoint 'v1' 322 | # @example Unsetting the string endpoint 323 | # endpoint nil 324 | def endpoint(value = nil) 325 | define_method(:endpoint) { value } 326 | end 327 | end 328 | 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /lib/api_smith/smash.rb: -------------------------------------------------------------------------------- 1 | require 'hashie/dash' 2 | 3 | module APISmith 4 | # Extends Hashie::Dash to suppress unknown keys when passing data, but 5 | # is configurable to raises an UnknownKey exception when accessing keys in the 6 | # Smash. 7 | # 8 | # APISmith::Smash is a subclass of Hashie::Dash that adds several features 9 | # making it suitable for use in writing api clients. Namely, 10 | # 11 | # * The ability to silence exceptions on unknown keys (vs. Raising NoMethodError) 12 | # * The ability to define conversion of incoming data via transformers 13 | # * The ability to define aliases for keys via the from parameter. 14 | # 15 | # @author Darcy Laycock 16 | # @author Steve Webb 17 | # 18 | # @example a simple, structured object with the most common use cases. 19 | # class MyResponse < APISmith::Smash 20 | # property :full_name, :from => :fullName 21 | # property :value_percentage, :transformer => :to_f 22 | # property :short_name 23 | # property :created, :transformer => lambda { |v| Date.parse(v) } 24 | # end 25 | # 26 | # response = MyResponse.new({ 27 | # :fullName => "Bob Smith", 28 | # :value_percentage => "10.5", 29 | # :short_name => 'Bob', 30 | # :created => '2010-12-28' 31 | # }) 32 | # 33 | # p response.short_name # => "Bob" 34 | # p response.full_name # => "Bob Smith" 35 | # p response.value_percentage # => 10.5 36 | # p response.created.class # => Date 37 | # 38 | class Smash < Hashie::Dash 39 | # When we access an unknown property, we raise the unknown key instead of 40 | # a NoMethodError on undefined keys so that we can do a target rescue. 41 | class UnknownKey < StandardError; end 42 | 43 | # Returns a class-specific hash of transformers, containing the attribute 44 | # name mapped to the transformer that responds to call. 45 | # @return The hash of transformers. 46 | def self.transformers 47 | (@transformers ||= {}) 48 | end 49 | 50 | # Returns a class-specific hash of incoming keys and their resultant 51 | # property name, useful for mapping non-standard names (e.g. displayName) 52 | # to their more ruby-like equivelant (e.g. display_name). 53 | # @return The hash of key mappings. 54 | def self.key_mapping 55 | (@key_mapping ||= {}) 56 | end 57 | 58 | # Test if the object should raise a NoMethodError exception on unknown 59 | # property accessors or whether it should be silenced. 60 | # 61 | # @return true if an exception will be raised when accessing an unknown key 62 | # else, false. 63 | def self.exception_on_unknown_key? 64 | defined?(@exception_on_unknown_key) && @exception_on_unknown_key 65 | end 66 | 67 | # Sets whether or not Smash should raise NoMethodError on an unknown key. 68 | # Sets it for the current class. 69 | # 70 | # @param [Boolean] value true to throw exceptions. 71 | def self.exception_on_unknown_key=(value) 72 | @exception_on_unknown_key = value 73 | end 74 | self.exception_on_unknown_key = false 75 | 76 | # Sets the transformer that is invoked when the given key is set. 77 | # 78 | # @param [Symbol] key The key should this transformer operate on 79 | # @param [#call] value If a block isn't given, used to transform via #call. 80 | # @param [Block] blk The block used to transform the key. 81 | def self.transformer_for(key, value = nil, &blk) 82 | if blk.nil? && value 83 | blk = value.respond_to?(:call) ? value : value.to_sym.to_proc 84 | end 85 | raise ArgumentError, 'must provide a transformation' if blk.nil? 86 | transformers[key.to_s] = blk 87 | # For each subclass, set the transformer. 88 | Array(@subclasses).each { |klass| klass.transformer_for(key, value) } 89 | end 90 | 91 | # Hook to make it inherit instance variables correctly. Called once 92 | # the Smash is inherited from in another object to maintain state. 93 | def self.inherited(klass) 94 | super 95 | klass.instance_variable_set '@transformers', transformers.dup 96 | klass.instance_variable_set '@key_mapping', key_mapping.dup 97 | klass.instance_variable_set '@exception_on_unknown_key', exception_on_unknown_key? 98 | end 99 | 100 | # Create a new property (i.e., hash key) for this Object type. This method 101 | # allows for converting property names and defining custom transformers for 102 | # more complex types. 103 | # 104 | # @param [Symbol] property_name The property name (duh). 105 | # @param [Hash] options 106 | # @option options [String, Array] :from Also accept values for this property when 107 | # using the key(s) specified in from. 108 | # @option options [Block] :transformer Specify a class or block to use when transforming the data. 109 | def self.property(property_name, options = {}) 110 | super 111 | if options[:from] 112 | property_name = property_name.to_s 113 | Array(options[:from]).each do |k| 114 | key_mapping[k.to_s] = property_name 115 | end 116 | end 117 | if options[:transformer] 118 | transformer_for property_name, options[:transformer] 119 | end 120 | end 121 | 122 | # Does this Smash class contain a specific property (key), 123 | # or does it have a key mapping (via :from) 124 | # 125 | # @param [Symbol] key the property to test for. 126 | # @return [Boolean] true if this class contains the key; else, false. 127 | def self.property?(key) 128 | super || key_mapping.has_key?(key.to_s) 129 | end 130 | 131 | # Automates type conversion (including on Array and Hashes) to this type. 132 | # Used so we can pass this class similarily to how we pass lambdas as an 133 | # object, primarily for use as transformers. 134 | # 135 | # @param [Object] the object to attempt to convert. 136 | # @return [Array, Smash] The converted object / array of objects if 137 | # possible, otherwise nil. 138 | def self.call(value) 139 | if value.is_a?(Array) 140 | value.map { |v| call v }.compact 141 | elsif value.is_a?(Hash) 142 | new value 143 | else 144 | nil 145 | end 146 | end 147 | 148 | # Access the value responding to a key, normalising the key into a form 149 | # we know (e.g. processing the from value to convert it to the actual 150 | # property name). 151 | # 152 | # @param [Symbol] property the key to check for. 153 | # @return The value corresponding to property. nil if it does not exist. 154 | def [](property) 155 | super transform_key(property) 156 | rescue UnknownKey 157 | nil 158 | end 159 | 160 | # Sets the value for a given key. Transforms the key first (e.g. taking into 161 | # account from values) and transforms the property using any transformers. 162 | # 163 | # @param [Symbol] property the key to set. 164 | # @param [String] value the value to set. 165 | # @return If the property exists value is returned; else, nil. 166 | def []=(property, value) 167 | key = transform_key(property) 168 | super key, transform_property(key, value) 169 | rescue UnknownKey 170 | nil 171 | end 172 | 173 | private 174 | 175 | # Overrides the Dashie check to raise a custom exception that we can 176 | # rescue from when the key is unknown. 177 | def assert_property_exists!(property) 178 | has_property = self.class.property?(property) 179 | unless has_property 180 | exception = self.class.exception_on_unknown_key? ? NoMethodError : UnknownKey 181 | raise exception, "The property '#{property}' is not defined on this #{self.class.name}" 182 | end 183 | end 184 | 185 | # Transforms a given key into it's normalised alternative, making it 186 | # suitable for automatically mapping external objects into a useable 187 | # local version. 188 | # @param [Symbol, String] key the starting key, pre-transformation 189 | # @return [String] the transformed key, ready for use internally. 190 | def transform_key(key) 191 | self.class.key_mapping[key.to_s] || default_key_transformation(key) 192 | end 193 | 194 | # By default, we transform the key using #to_s, making it useable 195 | # as a hash index. If you want to, for example, add leading underscores, 196 | # you're do so here. 197 | def default_key_transformation(key) 198 | key.to_s 199 | end 200 | 201 | # Given a key and a value, applies any incoming data transformations as appropriate. 202 | # @param [String, Symbol] key the property key 203 | # @param [Object] value the incoming value of the given property 204 | # @return [Object] the transformed value for the given key 205 | # @see Smash.transformer_for 206 | def transform_property(key, value) 207 | transformation = self.class.transformers[key.to_s] 208 | transformation ? transformation.call(value) : value 209 | end 210 | 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/api_smith/version.rb: -------------------------------------------------------------------------------- 1 | module APISmith 2 | # The current version of API Smith 3 | VERSION = "1.3.0".freeze 4 | end 5 | -------------------------------------------------------------------------------- /lib/api_smith/web_mock_extensions.rb: -------------------------------------------------------------------------------- 1 | module APISmith 2 | # A set of extensions to make using APISmith with WebMock (or most test utilities in general) 3 | # simpler when it comes checking values. Please note this is primarily intended for use with 4 | # rspec due to dependence on subject in some places. 5 | # 6 | # @author Darcy Laycock 7 | # @author Steve Webb 8 | module WebMockExtensions 9 | 10 | # Returns the class of the current subject 11 | # @return [Class] the subject class 12 | def subject_api_class 13 | subject.is_a?(Class) ? subject : subject.class 14 | end 15 | 16 | # Returns an instance of the subject class, created via allocate (vs. new). Useful 17 | # for giving access to utility methods used inside of the class without having to\ 18 | # initialize a new client. 19 | # @return [Object] the instance 20 | def subject_class_instance 21 | @subject_class_instance ||= subject_api_class.allocate 22 | end 23 | 24 | # Expands the given path relative to the API for the current subject class. 25 | # Namely, this makes it possible to convert a relative path to an endpoint-specified 26 | # path. 27 | # @param [String] path the path to expand, minus endpoint etc. 28 | # @return [String] the expanded path 29 | def api_url_for(path) 30 | path = subject_class_instance.send(:path_for, path) 31 | base_uri = subject_api_class.base_uri 32 | File.join base_uri, path 33 | end 34 | 35 | # Short hand for #stub_request that lets you give it a relative path prior to expanding it. 36 | # @param [:get, :post, :put, :delete] the verb for the request 37 | # @param [String] the relative path for the api 38 | # @return [Object] the result from stub_request 39 | def stub_api(type, path) 40 | stub_request type, api_url_for(path) 41 | end 42 | 43 | end 44 | end -------------------------------------------------------------------------------- /spec/api_smith/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APISmith::Base do 4 | 5 | it 'should be a class' do 6 | APISmith::Base.should be_a(Class) 7 | end 8 | 9 | it 'should mixin the client' do 10 | APISmith::Base.should be < APISmith::Client 11 | end 12 | 13 | it 'should mixin httparty' do 14 | APISmith::Base.should be < HTTParty 15 | end 16 | 17 | it 'should work in classes with arguments on their initializer' do 18 | klass = Class.new(APISmith::Base) do 19 | def initialize(options = {}) 20 | super 21 | end 22 | end 23 | expect { klass.new }.to_not raise_error(ArgumentError) 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /spec/api_smith/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APISmith::Client do 4 | 5 | let(:client_klass) do 6 | Class.new do 7 | include APISmith::Client 8 | base_uri "http://sham.local" 9 | end 10 | end 11 | 12 | let(:client) { client_klass.new } 13 | 14 | describe 'instrumentation' do 15 | subject do 16 | Class.new(client_klass) do 17 | def instrument_request(*args) 18 | 'custom_response' 19 | end 20 | end.new 21 | end 22 | 23 | it 'should allow you to customize reponse' do 24 | subject.get('/echo').should == 'custom_response' 25 | end 26 | end 27 | 28 | it 'should let you provide instrumentation' do 29 | second_klass = Class.new(client_klass) do 30 | attr_accessor :hits 31 | def instrument_request(*args) 32 | (self.hits ||= []) << args 33 | yield if block_given? 34 | end 35 | end 36 | client = second_klass.new 37 | client.get('/echo') 38 | hits = client.hits 39 | hits.should_not be_nil 40 | hits.should_not be_empty 41 | hit = hits.first 42 | hit[0].should == :get 43 | hit[1].should include "/echo" 44 | end 45 | 46 | it 'should allow you to perform get requests' do 47 | client.get('/echo').should == {"verb" => "get", "echo" => nil} 48 | end 49 | 50 | it 'should allow you to perform post requests' do 51 | client.post('/echo').should == {"verb" => "post", "echo" => nil} 52 | end 53 | 54 | it 'should allow you to perform put requests' do 55 | client.put('/echo').should == {"verb" => "put", "echo" => nil} 56 | end 57 | 58 | it 'should allow you to perform delete requests' do 59 | client.delete('/echo').should == {"verb" => "delete", "echo" => nil} 60 | end 61 | 62 | it 'should default to returning a httparty response' do 63 | response = client.get('/echo') 64 | response.class.should == HTTParty::Response 65 | end 66 | 67 | describe 'passing options' do 68 | 69 | it 'should allow you to pass extra query string options' do 70 | response = client.get('/echo', :extra_query => {:echo => "Hello"}) 71 | response["echo"].should == "Hello" 72 | end 73 | 74 | it 'should work with ampersands as expected' do 75 | response = client.get('/echo', :extra_query => {:echo => "a & b"}) 76 | response["echo"].should == "a & b" 77 | end 78 | 79 | it 'should allow you to pass extra body options' do 80 | response = client.post('/echo', :extra_body => {:echo => "Hello"}) 81 | response["echo"].should == "Hello" 82 | end 83 | 84 | it 'should allow you to pass extra request options' do 85 | mock.proxy(client_klass).get('/a', hash_including(:awesome => true)) 86 | client.get '/a', :extra_request => {:awesome => true} 87 | end 88 | 89 | it 'should let you add query options on an instance level' do 90 | client.send :add_query_options!, :echo => "Hello" 91 | client.get('/echo')["echo"].should == "Hello" 92 | end 93 | 94 | it 'should let you add body options on an instance level' do 95 | client.send :add_body_options!, :echo => "Hello" 96 | client.post('/echo')["echo"].should == "Hello" 97 | end 98 | 99 | it 'should let you add request options on an instance level' do 100 | mock.proxy(client_klass).get('/a', hash_including(:awesome => true)) 101 | client.send :add_request_options!, :awesome => true 102 | client.get('/a') 103 | end 104 | 105 | it 'should let you override the base level body options' do 106 | mock(client).base_body_options { {:echo => "Hello"} } 107 | client.post('/echo')["echo"].should == "Hello" 108 | end 109 | 110 | it 'should let you override the base level query string options' do 111 | mock(client).base_query_options { {:echo => "Hello"} } 112 | client.get('/echo')["echo"].should == "Hello" 113 | end 114 | 115 | it 'should let you override the base level request options' do 116 | mock.proxy(client_klass).get('/a', hash_including(:awesome => true)) 117 | mock(client).base_request_options { {:awesome => true} } 118 | client.get('/a') 119 | end 120 | 121 | end 122 | 123 | describe 'unpacking requests' do 124 | 125 | it 'should let you specify a response container' do 126 | client.get('/nested', :response_container => %w(response)).should == { 127 | "name" => "Steve" 128 | } 129 | end 130 | 131 | it 'should handle indices correctly' do 132 | client.get('/namespaced/complex', :response_container => ["response", "data", 0, "inner"]).should == { 133 | "name" => "Charles", 134 | "secret_identity" => true 135 | } 136 | end 137 | 138 | it 'should let you override the default response container' do 139 | mock(client).default_response_container('/namespaced/test', anything) { %w(response age) } 140 | client.get('/namespaced/test').should == 20 141 | end 142 | 143 | it 'should let you always skip the response container' do 144 | dont_allow(client).default_response_container.with_any_args 145 | client.get('/namespaced/test', :response_container => nil).should == { 146 | "response" => { 147 | "age" => 20, 148 | "name" => "Roger" 149 | } 150 | } 151 | end 152 | 153 | end 154 | 155 | describe 'transforming requests' do 156 | 157 | let(:my_smash) do 158 | Class.new(APISmith::Smash).tap do |t| 159 | t.property :name 160 | end 161 | end 162 | 163 | it 'should let you pass a transformer' do 164 | response = client.get('/simple', :transform => lambda { |v| v["name"].upcase }) 165 | response.should == "DARCY" 166 | end 167 | 168 | it 'should use .call on the transformer' do 169 | transformer = Object.new 170 | mock(transformer).call(rr_satisfy { |x| x.to_hash == {"name" => "Darcy"}}) { 42 } 171 | response = client.get('/simple', :transform => transformer) 172 | response.should == 42 173 | end 174 | 175 | it 'should transform the unpacked data' do 176 | transformer = lambda { |v| v.to_s.downcase.reverse } 177 | response = client.get('simple', :response_container => 'name', :transform => transformer) 178 | response.should == 'ycrad' 179 | end 180 | 181 | it 'should work with smash transformers for single objects' do 182 | response = client.get('/nested', :transform => my_smash, :response_container => %w(response)) 183 | response.should be_kind_of my_smash 184 | response.name.should == 'Steve' 185 | end 186 | 187 | it 'should work with smash transformers for collections' do 188 | response = client.get('/collection', :transform => my_smash, :response_container => %w(response)) 189 | response.should be_kind_of Array 190 | response.should be_all { |item| item.kind_of?(my_smash) } 191 | response.map(&:name).should == ["Bob", "Reginald"] 192 | end 193 | 194 | it 'should work with the transformer option as well' do 195 | response = client.get('/simple', :transformer => lambda { |v| v["name"].upcase }) 196 | response.should == "DARCY" 197 | end 198 | 199 | end 200 | 201 | describe 'checking for errors' do 202 | 203 | it 'should invoke the errors hook' do 204 | mock(client).check_response_errors(anything) 205 | client.get('/simple') 206 | end 207 | 208 | it 'should do it before unpack the response' do 209 | mock(client).check_response_errors(rr_satisfy { |x| x.to_hash == {"response" => {"name" => "Steve"}} }) 210 | client.get('/nested', :response_container => %w(response)) 211 | end 212 | 213 | it 'should let you prevent unpacking / transformation from happening' do 214 | transformer = Object.new 215 | dont_allow(transformer).call.with_any_args 216 | mock(client).check_response_errors(anything) { raise StandardError } 217 | expect do 218 | client.get('/simple', :transform => transformer) 219 | end.to raise_error(StandardError) 220 | end 221 | 222 | end 223 | 224 | describe 'endpoints' do 225 | 226 | it 'should act correctly without an endpoint' do 227 | client.send(:endpoint).should be_nil 228 | client.send(:path_for, 'test').should == "/test" 229 | client.get('a')["a"].should == "outer" 230 | end 231 | 232 | it 'should let you set an endpoint at the class level' do 233 | client_klass.endpoint 'namespaced' 234 | client.send(:endpoint).should == 'namespaced' 235 | client.send(:path_for, 'test').should == "/namespaced/test" 236 | client.get('a')["a"].should == "namespaced" 237 | end 238 | 239 | it 'should let you override it on an instance level' do 240 | mock(client).endpoint { 'test/nested' } 241 | client.send(:path_for, 'test2').should == "/test/nested/test2" 242 | end 243 | 244 | it 'should let you skip the endpoint' do 245 | client_klass.endpoint 'namespaced' 246 | client.get('/a', :extra_request => {:skip_endpoint => true})["a"].should == "outer" 247 | end 248 | 249 | end 250 | 251 | end 252 | -------------------------------------------------------------------------------- /spec/api_smith/smash_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe APISmith::Smash do 4 | 5 | let(:my_smash) { Class.new(APISmith::Smash) } 6 | 7 | describe 'transformers' do 8 | 9 | it 'should let you define a transformer via transformer_for' do 10 | my_smash.property :name 11 | my_smash.transformers['name'].should be_nil 12 | my_smash.transformer_for :name, lambda { |v| v.to_s.upcase } 13 | my_smash.transformers['name'].should_not be_nil 14 | my_smash.transformers['name'].call('a').should == 'A' 15 | end 16 | 17 | it 'should let you pass a block to transformer_for' do 18 | my_smash.property :name 19 | my_smash.transformers['name'].should be_nil 20 | my_smash.transformer_for(:name) { |v| v.to_s.upcase } 21 | my_smash.transformers['name'].should_not be_nil 22 | my_smash.transformers['name'].call('a').should == 'A' 23 | end 24 | 25 | it 'should accept a symbol for transformer' do 26 | my_smash.property :name 27 | my_smash.transformers['name'].should be_nil 28 | my_smash.transformer_for :name, :to_i 29 | my_smash.transformers['name'].should_not be_nil 30 | my_smash.transformers['name'].call('1').should == 1 31 | end 32 | 33 | it 'should let you define a transformer via the :transformer property option' do 34 | my_smash.transformers['name'].should be_nil 35 | my_smash.property :name, :transformer => lambda { |v| v.to_s.upcase } 36 | my_smash.transformers['name'].should_not be_nil 37 | my_smash.transformers['name'].call('a').should == 'A' 38 | end 39 | 40 | it 'should automatically transform the incoming value' do 41 | my_smash.property :count, :transformer => lambda { |v| v.to_i } 42 | instance = my_smash.new 43 | instance.count = '1' 44 | instance.count.should == 1 45 | end 46 | 47 | end 48 | 49 | describe 'key transformations' do 50 | 51 | it 'should let you specify it via from' do 52 | my_smash.property :name, :from => :fullName 53 | my_smash.key_mapping['fullName'].should == 'name' 54 | my_smash.new(:fullName => 'Bob').name.should == 'Bob' 55 | end 56 | 57 | it 'should alias it for reading' do 58 | my_smash.property :name, :from => :fullName 59 | my_smash.new(:name => 'Bob')[:fullName].should == 'Bob' 60 | end 61 | 62 | it 'should alias it for writing' do 63 | my_smash.property :name, :from => :fullName 64 | instance = my_smash.new 65 | instance[:fullName] = 'Bob' 66 | instance.name.should == 'Bob' 67 | end 68 | 69 | end 70 | 71 | describe 'inheritance' do 72 | 73 | let(:parent_smash) { Class.new(APISmith::Smash) } 74 | let(:client_smash) { Class.new(parent_smash) } 75 | 76 | it 'should not overwrite parent class transformers' do 77 | parent_smash.transformers['a'].should be_nil 78 | client_smash.transformers['a'].should be_nil 79 | client_smash.transformer_for :a, :to_s 80 | parent_smash.transformers['a'].should be_nil 81 | client_smash.transformers['a'].should_not be_nil 82 | end 83 | 84 | it 'should not overwrite parent class key mapping' do 85 | parent_smash.key_mapping['b'].should be_nil 86 | client_smash.key_mapping['b'].should be_nil 87 | client_smash.property :a, :from => :b 88 | parent_smash.key_mapping['b'].should be_nil 89 | client_smash.key_mapping['b'].should_not be_nil 90 | end 91 | 92 | it 'should not overwrite the parent classes unknown key error' do 93 | parent_smash.exception_on_unknown_key?.should be_false 94 | client_smash.exception_on_unknown_key?.should be_false 95 | client_smash.exception_on_unknown_key = true 96 | parent_smash.exception_on_unknown_key?.should be_false 97 | client_smash.exception_on_unknown_key?.should be_true 98 | end 99 | 100 | end 101 | 102 | describe 'overriding the default key transformations' do 103 | 104 | it 'should let you override the default transformation method' do 105 | my_smash.property :name 106 | my_smash.class_eval do 107 | def default_key_transformation(key) 108 | key.to_s.downcase.gsub(/\d/, '') 109 | end 110 | end 111 | smash = my_smash.new 112 | smash[:NAME1] = 'Bob Smith' 113 | smash.name.should == 'Bob Smith' 114 | end 115 | 116 | it 'should default to transforming via to_s' do 117 | smash = my_smash.new 118 | smash.send(:default_key_transformation, :another).should == 'another' 119 | end 120 | 121 | end 122 | 123 | describe 'extending Hashie::Dash' do 124 | 125 | it 'should let you swallow errors on unknown keys' do 126 | my_smash.properties.should_not include(:name) 127 | my_smash.exception_on_unknown_key?.should be_false 128 | expect do 129 | my_smash.new(:name => 'Test') 130 | end.should_not raise_error 131 | my_smash.exception_on_unknown_key?.should be_false 132 | end 133 | 134 | it 'should raise an exception correctly when not ignoring unknown keys' do 135 | my_smash.properties.should_not include(:name) 136 | my_smash.exception_on_unknown_key = true 137 | my_smash.exception_on_unknown_key?.should be_true 138 | expect do 139 | my_smash.new[:name] = 'Test' 140 | end.to raise_error(NoMethodError) 141 | expect do 142 | my_smash.new[:name] 143 | end.to raise_error(NoMethodError) 144 | my_smash.exception_on_unknown_key?.should be_true 145 | end 146 | 147 | it 'should default to ignoring unknown key errors' do 148 | klass = Class.new(APISmith::Smash) 149 | klass.exception_on_unknown_key?.should be_false 150 | expect do 151 | klass.new[:my_imaginary_key] = 'of doom' 152 | klass.new[:my_imaginary_key] 153 | end.to_not raise_error(NoMethodError) 154 | end 155 | 156 | it 'should include aliases in :from when checking if properties are valid' do 157 | my_smash.should_not be_property(:name) 158 | my_smash.should_not be_property(:fullName) 159 | my_smash.property :name, :from => :fullName 160 | my_smash.should be_property(:name) 161 | my_smash.should be_property(:fullName) 162 | end 163 | 164 | end 165 | 166 | describe 'being a callable object' do 167 | 168 | before :each do 169 | my_smash.property :name 170 | my_smash.property :age, :transformer => :to_i 171 | end 172 | 173 | it 'should respond to call' do 174 | my_smash.should respond_to(:call) 175 | end 176 | 177 | it 'should correctly transform a hash' do 178 | instance = my_smash.call(:name => 'Bob', :age => '18') 179 | instance.should be_a(my_smash) 180 | instance.name.should == 'Bob' 181 | instance.age.should == 18 182 | end 183 | 184 | it 'should correctly transform an array' do 185 | instance = my_smash.call([{:name => 'Bob', :age => '18'}, {:name => 'Rick', :age => '19'}]) 186 | instance.should be_a(Array) 187 | instance.first.should be_a(my_smash) 188 | instance.first.name.should == 'Bob' 189 | instance.first.age.should == 18 190 | instance.last.should be_a(my_smash) 191 | instance.last.name.should == 'Rick' 192 | instance.last.age.should == 19 193 | end 194 | 195 | it 'should return nil for unknown types' do 196 | my_smash.call(100).should be_nil 197 | my_smash.call(nil).should be_nil 198 | my_smash.call("Oh look, a pony!").should be_nil 199 | end 200 | 201 | it 'should return itself when passed in' do 202 | instance = my_smash.new(:name => "Bob", :age => 18) 203 | transformed = my_smash.call(instance) 204 | transformed.should_not be_nil 205 | transformed.should be_kind_of my_smash 206 | transformed.name.should == "Bob" 207 | transformed.age.should == 18 208 | end 209 | 210 | end 211 | 212 | end 213 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= 'test' 2 | 3 | require 'rspec' 4 | 5 | require 'pathname' 6 | require 'bundler/setup' 7 | 8 | Bundler.setup 9 | Bundler.require :default, :test 10 | 11 | require 'api_smith' 12 | require 'rr' 13 | require 'json' 14 | 15 | Dir[Pathname(__FILE__).dirname.join("support/**/*.rb")].each { |f| require f } 16 | 17 | RSpec.configure do |config| 18 | config.mock_with :rr 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/fake_endpoints.rb: -------------------------------------------------------------------------------- 1 | require 'sham_rack' 2 | 3 | ShamRack.at("sham.local").sinatra do 4 | 5 | def json!(response) 6 | content_type "application/json" 7 | JSON.dump response 8 | end 9 | 10 | get '/simple' do 11 | json! :name => "Darcy" 12 | end 13 | 14 | get '/nested' do 15 | json! :response => { 16 | :name => "Steve" 17 | } 18 | end 19 | 20 | get '/collection' do 21 | json! :response => [ 22 | {:name => "Bob"}, 23 | {:name => "Reginald"} 24 | ] 25 | end 26 | 27 | get '/a' do 28 | json! :a => "outer" 29 | end 30 | 31 | get '/namespaced/a' do 32 | json! :a => "namespaced" 33 | end 34 | 35 | get '/namespaced/test' do 36 | json! :response => {:age => 20, :name => "Roger"} 37 | end 38 | 39 | get '/namespaced/complex' do 40 | json! :response => { 41 | :data => [{ 42 | :inner => {:name => 'Charles', :secret_identity => true} 43 | }] 44 | } 45 | end 46 | 47 | %w(get post put delete).each do |verb| 48 | send(verb, '/echo') do 49 | json! :verb => verb, :echo => params[:echo] 50 | end 51 | end 52 | 53 | get '/erroring' do 54 | json! :error_name => 'Totally confused.' 55 | end 56 | 57 | end --------------------------------------------------------------------------------