├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── weasel_diesel ├── lib ├── documentation.rb ├── json_response_verification.rb ├── params.rb ├── params_verification.rb ├── response.rb ├── weasel_diesel.rb ├── weasel_diesel │ ├── cli.rb │ ├── doc_generator │ │ ├── _input_params.erb │ │ ├── _response_element.erb │ │ └── template.erb │ ├── dsl.rb │ └── version.rb └── ws_list.rb ├── spec ├── hello_world_service.rb ├── json_response_description_spec.rb ├── json_response_verification_spec.rb ├── params_verification_spec.rb ├── preferences_service.rb ├── spec_helper.rb ├── test_services.rb ├── wd_documentation_spec.rb ├── wd_params_spec.rb ├── wd_spec.rb └── ws_list_spec.rb └── weasel_diesel.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - jruby 6 | - rbx-19mode 7 | - ruby-head 8 | - jruby-head 9 | 10 | matrix: 11 | allow_failures: 12 | - rvm: ruby-head 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # WeaselDiesel Changelog 2 | 3 | All changes can be seen on GitHub and git tags are used to isolate each 4 | release. 5 | 6 | ## HEAD 7 | * Remove deprecated #controller_dispatch. 8 | 9 | ## 1.3.0 10 | * Move documentation generation from wd_sinatra into Weasel-Diesel. 11 | * Drop support for Ruby 1.8.7. 12 | * Fix rspec deprecation: `expect { }.not_to raise_error(SpecificErrorClass)` 13 | * DSL now only extends the top level main object. 14 | 15 | ## 1.2.2: 16 | * Added support for anonymous top level arrays. 17 | 18 | ## 1.2.1: 19 | 20 | * Modified the way an empty string param is cast/verified. If a param is 21 | passed as an empty string but the param isn't specified as a string, the 22 | param is nullified. So if you pass `{'id' => ''}`, and `id` is set to be 23 | an integer param, the cast params will look like that: `{'id' => nil}`, 24 | however if `name` is a string param and `{'name' => ''}` is passed, the 25 | value won't be nullified. 26 | 27 | ## 1.2.0: 28 | 29 | * All service urls are now stored with a prepended slash (if not defined 30 | with one). `WDList.find(, )` will automatically find the 31 | right url even if the passed url doesn't start by a '/'. This should be 32 | backward compatible with most code out there as long as your code 33 | doesn't do a direct lookup on the url. 34 | The reason for this change is that I think I made a design mistake when 35 | I decided to define urls without a leading '/'. Sinatra and many other 36 | frameworks use that leading slash and it makes sense to do the same. 37 | 38 | * Adding a duplicate service (same url and verb) now raises an exception 39 | instead of silently ignoring the duplicate. 40 | 41 | * Upgraded test suite to properly use `WDList.find`. 42 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in weasel_diesel.gemspec 4 | gemspec 5 | 6 | gem "json", :platform => :jruby 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT: http://mattaimonetti.mit-license.org 2 | 3 | Copyright (c) 2013 Matt Aimonetti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -- 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Service DSL 2 | 3 | [![CI Build Status](https://secure.travis-ci.org/mattetti/Weasel-Diesel.png?branch=master)](http://travis-ci.org/mattetti/Weasel-Diesel) 4 | 5 | Weasel Diesel is a DSL to describe and document your web API. 6 | 7 | To get you going quickly, see the [generator for sinatra apps](https://github.com/mattetti/wd-sinatra). 8 | The wd_sinatra gem allows you to generate the structure for a sinatra app using Weasel Diesel and with lots of goodies. 9 | Updating is trivial since the core features are provided by this library and the wd_sinatra gem. 10 | 11 | You can also check out this Sinatra-based [example 12 | application](https://github.com/mattetti/sinatra-web-api-example) that 13 | you can fork and use as a base for your application. 14 | 15 | * API Docs: http://rubydoc.info/gems/weasel_diesel/frames 16 | * Google Group: https://groups.google.com/forum/#!forum/weaseldiesel 17 | 18 | DSL examples: 19 | 20 | ``` ruby 21 | describe_service "/hello_world" do |service| 22 | service.formats :json 23 | service.http_verb :get # default verb, can be ommitted. 24 | service.disable_auth # on by default 25 | 26 | # INPUT 27 | service.param.string :name, :default => 'World', :doc => "The name of the person to greet." 28 | 29 | # OUTPUT 30 | service.response do |response| 31 | response.object do |obj| 32 | obj.string :message, :doc => "The greeting message sent back. Defaults to 'World'" 33 | obj.datetime :at, :doc => "The timestamp of when the message was dispatched" 34 | end 35 | end 36 | 37 | # DOCUMENTATION 38 | service.documentation do |doc| 39 | doc.overall "This service provides a simple hello world implementation example." 40 | doc.example "curl -I 'http://localhost:9292/hello_world?name=Matt'" 41 | end 42 | 43 | # ACTION/IMPLEMENTATION (specific to the sinatra app example, can 44 | # instead be set to call a controller action) 45 | service.implementation do 46 | {:message => "Hello #{params[:name]}", :at => Time.now}.to_json 47 | end 48 | 49 | end 50 | ``` 51 | 52 | Or a more complex example using XML: 53 | 54 | ``` ruby 55 | SpecOptions = ['RSpec', 'Bacon'] # usually pulled from a model 56 | 57 | describe_service "/wsdsl/test.xml" do |service| 58 | service.formats :xml, :json 59 | service.http_verb :get 60 | 61 | # INPUT 62 | service.params do |p| 63 | p.string :framework, :in => SpecOptions, :null => false, :required => true 64 | 65 | p.datetime :timestamp, 66 | :default => Time.now, 67 | :doc => "The test framework used, could be one of the two following: #{SpecOptions.join(", ")}." 68 | 69 | p.string :alpha, :in => ['a', 'b', 'c'] 70 | p.string :version, 71 | :null => false, 72 | :doc => "The version of the framework to use." 73 | 74 | p.integer :num, :minvalue => 42 75 | p.namespace :user do |user| 76 | user.integer :id, :required => :true 77 | end 78 | end 79 | 80 | # OUTPUT 81 | # the response contains a list of player creation ratings each object in the list 82 | service.response do |response| 83 | response.element(:name => "player_creation_ratings") do |e| 84 | e.attribute :id => :integer, :doc => "id doc" 85 | e.attribute :is_accepted => :boolean, :doc => "is accepted doc" 86 | e.attribute :name => :string, :doc => "name doc" 87 | 88 | e.array :name => 'player_creation_rating', :type => 'PlayerCreationRating' do |a| 89 | a.attribute :comments => :string, :doc => "comments doc" 90 | a.attribute :player_id => :integer, :doc => "player_id doc" 91 | a.attribute :rating => :integer, :doc => "rating doc" 92 | a.attribute :username => :string, :doc => "username doc" 93 | end 94 | end 95 | end 96 | 97 | # DOCUMENTATION 98 | service.documentation do |doc| 99 | # doc.overall 100 | doc.overall <<-DOC 101 | This is a test service used to test the framework. 102 | DOC 103 | 104 | # doc.example 105 | doc.example <<-DOC 106 | The most common way to use this service looks like that: 107 | http://example.com/wsdsl/test.xml?framework=rspec&version=2.0.0 108 | DOC 109 | end 110 | end 111 | ``` 112 | 113 | ## INPUT DSL 114 | 115 | As shown in the two examples above, input parameters can be: 116 | * optional or required 117 | * namespaced 118 | * typed 119 | * marked as not being null if passed 120 | * set to have a value defined in a list 121 | * set to have a min value 122 | * set to have a min length 123 | * set to have a max value 124 | * set to have a max length 125 | * documented 126 | 127 | Most of these settings are used to verify the input requests. 128 | 129 | ### Supported defined types: 130 | 131 | * integer 132 | * float, decimal 133 | * string 134 | * boolean 135 | * array (comma delimited string) 136 | * binary, file 137 | 138 | #### Note regarding required vs optional params. 139 | 140 | You can't set a required param to be `:null => true`, if you do so, the 141 | setting will be ignored since all required params have to be present. 142 | 143 | If you set an optional param to be `:null => false`, the verification 144 | will only fail if the param was present in the request but the passed 145 | value is nil. You might want to use that setting if you have an optional 146 | param that, by definition isn't required but, if passed has to not be 147 | null. 148 | 149 | 150 | ### Validation and other param options 151 | 152 | You can set many rules to define an input parameter. 153 | Here is a quick overview of the available param options, check the specs for more examples. 154 | Options can be combined. 155 | 156 | * `required` by default the defined optional input parameters are 157 | optional. However their presence can be required by using this flag. 158 | (Setting `:null => true` will be ignored if the paramter is required) 159 | Example: `service.param.string :id, :required => true` 160 | * `in` or `options` limits the range of the possible values being 161 | passed. Example: `service.param.string :skills, :options %w{ruby scala clojure}` 162 | * `default` sets a value for your in case you don't pass one. Example: 163 | `service.param.datetime :timestamp, :default => Time.now.iso8601` 164 | * `min_value` forces the param value to be equal or greater than the 165 | option's value. Example: `service.param.integer :age, :min_value => 21 166 | * `max_value` forces the param value to be equal or less than the 167 | options's value. Example: `service.param.integer :votes, :max_value => 7 168 | * `min_length` forces the length of the param value to be equal or 169 | greater than the option's value. Example: `service.param.string :name, :min_length => 2` 170 | * `max_length` forces the length of the param value to be equal or 171 | lesser than the options's value. Example: `service.param.string :name, :max_length => 251` 172 | * `null` in the case of an optional parameter, if the parameter is being 173 | passed, the value can't be nil or empty. 174 | * `doc` document the param. 175 | 176 | ### Namespaced/nested object 177 | 178 | Input parameters can be defined nested/namespaced. 179 | This is particuliarly frequent when using Rails for instance. 180 | 181 | ```ruby 182 | service.params do |param| 183 | param.string :framework, 184 | :in => ['RSpec', 'Bacon'], 185 | :required => true, 186 | :doc => "The test framework used, could be one of the two following: #{WeaselDieselSpecOptions.join(", ")}." 187 | 188 | param.datetime :timestamp, :default => Time.now 189 | param.string :alpha, :in => ['a', 'b', 'c'] 190 | param.string :version, :null => false, :doc => "The version of the framework to use." 191 | param.integer :num, :min_value => 42, :max_value => 1000, :doc => "The number to test" 192 | param.string :name, :min_length => 5, :max_length => 25 193 | end 194 | 195 | service.params.namespace :user do |user| 196 | user.integer :id, :required => :true 197 | user.string :sex, :in => %Q{female, male} 198 | user.boolean :mailing_list, :default => true, :doc => "is the user subscribed to the ML?" 199 | user.array :skills, :in => %w{ruby js cooking} 200 | end 201 | 202 | service.params.namespace :attachment, :null => true do |attachment| 203 | attachment.string :url, :required => true 204 | end 205 | ``` 206 | 207 | 208 | 209 | Here is the same type of input but this time using a JSON jargon, 210 | `namespace` and `object` are aliases and can therefore can be used based 211 | on how the input type. 212 | 213 | ```ruby 214 | # INPUT using 1.9 hash syntax 215 | service.params do |param| 216 | param.integer :playlist_id, 217 | doc: "The ID of the playlist to which the track belongs.", 218 | required: true 219 | param.object :track do |track| 220 | track.string :title, 221 | doc: "The title of the track.", 222 | required: true 223 | track.string :album_title, 224 | doc: "The title of the album to which the track belongs.", 225 | required: true 226 | track.string :artist_name, 227 | doc: "The name of the track's artist.", 228 | required: true 229 | track.string :rdio_id, 230 | doc: "The Rdio ID of the track.", 231 | required: true 232 | end 233 | end 234 | ``` 235 | 236 | 237 | 238 | ## OUTPUT DSL 239 | 240 | 241 | ### JSON API example 242 | 243 | Consider the following JSON response: 244 | 245 | ``` 246 | { people: [ 247 | { 248 | id : 1, 249 | online : false, 250 | created_at : 123123123123, 251 | team : { 252 | id : 1231, 253 | score : 123.32 254 | } 255 | }, 256 | { 257 | id : 2, 258 | online : true, 259 | created_at : 123123123123, 260 | team: { 261 | id : 1233, 262 | score : 1.32 263 | } 264 | }, 265 | ] } 266 | ``` 267 | 268 | It would be described as follows: 269 | 270 | ``` ruby 271 | describe_service "/json_list" do |service| 272 | service.formats :json 273 | service.response do |response| 274 | response.array :people do |node| 275 | node.integer :id 276 | node.boolean :online 277 | node.datetime :created_at 278 | node.object :team do |team| 279 | team.integer :id 280 | team.float :score, :null => true 281 | end 282 | end 283 | end 284 | end 285 | ``` 286 | 287 | Nodes/elements can also use some meta-attributes including: 288 | 289 | * `key` : refers to an attribute name that is key to this object 290 | * `type` : refers to the type of object described, valuable when using JSON across OO based apps. 291 | 292 | JSON response validation can be done using an optional module as shown in 293 | (spec/json_response_verification_spec.rb)[https://github.com/mattetti/Weasel-Diesel/blob/master/spec/json_response_verification_spec.rb]. 294 | The goal of this module is to help automate API testing by 295 | validating the data structure of the returned object. 296 | 297 | Another simple examples: 298 | 299 | Actual output: 300 | ``` 301 | {"organization": {"name": "Example"}} 302 | ``` 303 | 304 | Output DSL: 305 | ``` Ruby 306 | describe_service "example" do |service| 307 | service.formats :json 308 | service.response do |response| 309 | response.object :organization do |node| 310 | node.string :name 311 | end 312 | end 313 | end 314 | ``` 315 | 316 | Actual output: 317 | ``` 318 | {"name": "Example"} 319 | ``` 320 | 321 | Output DSL: 322 | ``` Ruby 323 | describe_service "example" do |service| 324 | service.formats :json 325 | service.response do |response| 326 | response.object do |node| 327 | node.string :name 328 | end 329 | end 330 | end 331 | ``` 332 | 333 | ## Documentation generation 334 | 335 | ```bash 336 | $ weasel_diesel generate_doc 337 | ``` 338 | 339 | To generate documentation for the APIs you created in the api folder. The 340 | source path is the location of your ruby files. The destination is optional, 341 | 'doc' is the default. 342 | 343 | Here's a [sample](https://s3.amazonaws.com/f.cl.ly/items/3V1Q123b2E2c0z350V0n/index.html) 344 | of what the generator documentation looks like. 345 | 346 | ## Test Suite & Dependencies 347 | 348 | The test suite requires `rspec`, `rack`, and `sinatra` gems. 349 | 350 | ## Copyright 351 | 352 | Copyright (c) 2012 Matt Aimonetti. See LICENSE for 353 | further details. 354 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require "bundler/gem_tasks" 3 | require 'bundler' 4 | Bundler.setup 5 | require 'rspec/core' 6 | require 'rspec/core/rake_task' 7 | RSpec::Core::RakeTask.new(:spec) do |spec| 8 | spec.pattern = FileList['spec/**/*_spec.rb'] 9 | end 10 | 11 | task :default => :spec 12 | 13 | require 'yard' 14 | YARD::Rake::YardocTask.new 15 | -------------------------------------------------------------------------------- /bin/weasel_diesel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require_relative "../lib/weasel_diesel/cli" 4 | 5 | WeaselDiesel::CLI.start 6 | -------------------------------------------------------------------------------- /lib/documentation.rb: -------------------------------------------------------------------------------- 1 | class WeaselDiesel 2 | # Service documentation class 3 | # 4 | # @api public 5 | class Documentation 6 | 7 | # @api public 8 | attr_reader :desc 9 | 10 | # @api public 11 | attr_reader :params_doc 12 | 13 | # @api public 14 | attr_reader :namespaced_params 15 | 16 | # @api public 17 | attr_reader :examples 18 | 19 | # @api public 20 | attr_reader :elements 21 | 22 | # This class contains the documentation information regarding an element. 23 | # Currently, elements are only used in the response info. 24 | # 25 | # @api public 26 | class ElementDoc 27 | 28 | # @api public 29 | attr_reader :name, :attributes 30 | 31 | # @param [String] The element's name 32 | # @api public 33 | def initialize(name) 34 | # raise ArgumentError, "An Element doc needs to be initialize by passing a hash with a ':name' keyed entry." unless opts.is_a?(Hash) && opts.has_key?(:name) 35 | @name = name 36 | @attributes = {} 37 | end 38 | 39 | # @param [String] name The name of the attribute described 40 | # @param [String] desc The description of the attribute 41 | # @api public 42 | def attribute(name, desc) 43 | @attributes[name] = desc 44 | end 45 | 46 | end # of ElementDoc 47 | 48 | # Namespaced param documentation 49 | # 50 | # @api public 51 | class NamespacedParam 52 | 53 | # @return [String, Symbol] The name of the namespaced, usually a symbol 54 | # @api public 55 | attr_reader :name 56 | 57 | # @return [Hash] The list of params within the namespace 58 | # @api public 59 | attr_reader :params 60 | 61 | # @api public 62 | def initialize(name) 63 | @name = name 64 | @params = {} 65 | end 66 | 67 | # Sets the description/documentation of a specific namespaced param 68 | # 69 | # @return [String] 70 | # @api public 71 | def param(name, desc) 72 | @params[name] = desc 73 | end 74 | 75 | end 76 | 77 | # Initialize a Documentation object wrapping all the documentation aspect of the service. 78 | # The response documentation is a Documentation instance living inside the service documentation object. 79 | # 80 | # @api public 81 | def initialize 82 | @params_doc = {} 83 | @examples = [] 84 | @elements = [] 85 | @namespaced_params = [] 86 | end 87 | 88 | # Sets or returns the overall description 89 | # 90 | # @param [String] desc Service overall description 91 | # @api public 92 | # @return [String] The overall service description 93 | def overall(desc) 94 | if desc.nil? 95 | @desc 96 | else 97 | @desc = desc 98 | end 99 | end 100 | 101 | # Sets the description/documentation of a specific param 102 | # 103 | # @return [String] 104 | # @api public 105 | def params(name, desc) 106 | @params_doc[name] = desc 107 | end 108 | alias_method :param, :params 109 | 110 | # Define a new namespaced param and yield it to the passed block 111 | # if available. 112 | # 113 | # @return [Array] the namespaced params 114 | # @api public 115 | def namespace(ns_name) 116 | new_ns_param = NamespacedParam.new(ns_name) 117 | if block_given? 118 | yield(new_ns_param) 119 | end 120 | @namespaced_params << new_ns_param 121 | end 122 | alias :object :namespace 123 | 124 | def response 125 | @response ||= Documentation.new 126 | end 127 | 128 | # Service usage example 129 | # 130 | # @param [String] desc Usage example. 131 | # @return [Array] All the examples. 132 | # @api public 133 | def example(desc) 134 | @examples << desc 135 | end 136 | 137 | # Add a new element to the doc 138 | # currently only used for response doc 139 | # 140 | # @param [Hash] opts element's documentation options 141 | # @yield [ElementDoc] The new element doc. 142 | # @return [Array] 143 | # @api public 144 | def element(opts={}) 145 | element = ElementDoc.new(opts) 146 | yield(element) 147 | @elements << element 148 | end 149 | 150 | 151 | end # of Documentation 152 | end 153 | -------------------------------------------------------------------------------- /lib/json_response_verification.rb: -------------------------------------------------------------------------------- 1 | # Include this module in WeaselDiesel 2 | # to add response verification methods. 3 | # 4 | module JSONResponseVerification 5 | 6 | # Verifies the parsed body of a JSON response against the service's response description. 7 | # 8 | # @return [Array>] True/false and an array of errors. 9 | def verify(parsed_json_body) 10 | errors = [verify_element(parsed_json_body, response.nodes.first)] 11 | errors.flatten! 12 | [errors.empty?, errors] 13 | end 14 | 15 | alias :validate_hash_response :verify # backguard compatibility 16 | 17 | private 18 | 19 | # Recursively validates an element found when parsing a JSON. 20 | # 21 | # @param [Hash, Array, nil] el parsed JSON to be verified. 22 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 23 | # @param [TrueClass, FalseClass] verify_namespace if the nesting must be verified. 24 | # @return [Arrays] errors the list of errors encountered while verifying. 25 | def verify_element(el, expected, verify_namespace=true) 26 | if expected.name && verify_namespace 27 | if verified_namespace?(el, expected.name) 28 | el = el[expected.name.to_s] 29 | verify_namespace = false 30 | else 31 | return something_is_missing_error(expected) 32 | end 33 | else 34 | verify_namespace = true 35 | end 36 | if el.nil? 37 | something_is_missing_error(expected) 38 | elsif el.is_a?(Array) 39 | verify_array(el, expected, verify_namespace) 40 | else 41 | verify_object(el, expected, verify_namespace) 42 | end 43 | end 44 | 45 | # Verifies hash corresponding to a JSON response against a given namespace 46 | # 47 | # @param [Array] array array to be verified. 48 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 49 | # @return [TrueClass, FalseClass] if the nesting name found is correct. 50 | def verified_namespace?(hash, expected_name) 51 | hash.respond_to?(:has_key?) && hash.has_key?(expected_name.to_s) 52 | end 53 | 54 | # Validates an array found when parsing a JSON. 55 | # 56 | # @param [Array] array array to be verified. 57 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 58 | # @return [Arrays] errors the list of errors encountered while verifying. 59 | def verify_array(array, expected, verify_nesting) 60 | return wrong_type_error(array, expected.name, expected.type) unless expected.is_a?(WeaselDiesel::Response::Vector) 61 | expected = expected.elements && expected.elements.any? ? expected.elements.first : expected 62 | array.map{ |el| verify_element(el, expected, verify_nesting) } 63 | end 64 | 65 | # Validates a hash corresponding to a JSON object. 66 | # 67 | # @param [Hash] hash hash to be verified. 68 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 69 | # @return [Arrays] errors the list of errors encountered while verifying. 70 | def verify_object(hash, expected, verify_nesting) 71 | [verify_attributes(hash, expected)] + [verify_objects(hash, expected)] 72 | end 73 | 74 | # Validates the objects found in a hash corresponding to a JSON object. 75 | # 76 | # @param [Hash] hash hash representing a JSON object whose internal objects will be verified. 77 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 78 | # @return [Arrays] errors the list of errors encountered while verifying. 79 | def verify_objects(hash, expected) 80 | return [] unless expected.objects 81 | expected.objects.map do |expected| 82 | found = hash[expected.name.to_s] 83 | null_allowed = expected.respond_to?(:opts) && expected.opts[:null] 84 | if found.nil? 85 | null_allowed ? [] : something_is_missing_error(expected) 86 | else 87 | verify_element(found, expected, false) # don't verify nesting 88 | end 89 | end 90 | end 91 | 92 | # Validates the attributes found in a hash corresponding to a JSON object. 93 | # 94 | # @param [Hash] hash hash whose attributes will be verified. 95 | # @param [WDSL::Response::Element] expected the reference element defined in the response description. 96 | # @return [Arrays] errors the list of errors encountered while verifying. 97 | def verify_attributes(hash, expected) 98 | return [] unless expected.attributes 99 | expected.attributes.map{ |a| verify_attribute_value(hash[a.name.to_s], a) } 100 | end 101 | 102 | # Validates a value against a found in a hash corresponding to a JSON object. 103 | # 104 | # @param [value] value value to be verified. 105 | # @param [WDSL::Response::Attribute] expected the reference element defined in the response description. 106 | # @return [Arrays] errors the list of errors encountered while verifying. 107 | def verify_attribute_value(value, attribute) 108 | null_allowed = attribute.respond_to?(:opts) && !!attribute.opts[:null] 109 | if value.nil? 110 | null_allowed ? [] : wrong_type_error(value, attribute.name, attribute.type) 111 | else 112 | type = attribute.type 113 | return [] if type.to_sym == :string 114 | rule = ParamsVerification.type_validations[attribute.type.to_sym] 115 | puts "Don't know how to validate attributes of type #{type}" if rule.nil? 116 | (rule.nil? || value.to_s =~ rule) ? [] : wrong_type_error(value, attribute.name, attribute.type) 117 | end 118 | end 119 | 120 | # Returns an error message reporting that an expected data hasn't been found in the JSON response. 121 | # 122 | # @param [WDSL::Response::Element, WDSL::Response::Attribute] expected missing data. 123 | # @return [String] error message 124 | def something_is_missing_error(expected) 125 | "#{expected.name || 'top level'} Node/Object/Element is missing" 126 | end 127 | 128 | # Returns an error message reporting that a value doesn't correspond to an expected data type. 129 | # 130 | # @param [value] value which doesn't correspond to the expected type. 131 | # @param [data_name] data_name name of the data containing the value. 132 | # @param [expected_type] expected type. 133 | # @return [String] error message 134 | def wrong_type_error(value, data_name, expected_type) 135 | "#{data_name} was of wrong type, expected #{expected_type} and the value was #{value}" 136 | end 137 | 138 | end -------------------------------------------------------------------------------- /lib/params.rb: -------------------------------------------------------------------------------- 1 | class WeaselDiesel 2 | # Service params class letting you define param rules. 3 | # Usually not initialized directly but accessed via the service methods. 4 | # 5 | # @see WeaselDiesel#params 6 | # 7 | # @api public 8 | class Params 9 | 10 | # Namespaces have a name, and options. 11 | # 12 | # @api public 13 | class Namespace 14 | # @return [Symbol, String] name The name of the namespace. 15 | # @api public 16 | attr_reader :name 17 | 18 | # @return [Boolean] :null Can this namespace be null? 19 | # @api public 20 | attr_reader :null 21 | 22 | # @param [Symbol, String] name 23 | # The namespace's name 24 | # @param [Hash] opts The namespace options 25 | # @option opts [Boolean] :null Can this value be null? 26 | # @api public 27 | def initialize(name, opts={}) 28 | @name = name 29 | @null = opts[:null] || false 30 | end 31 | end # of Namespace 32 | 33 | # Params usually have a few rules used to validate requests. 34 | # Rules are not usually initialized directly but instead via 35 | # the service's #params accessor. 36 | # 37 | # @api public 38 | class Rule 39 | 40 | # @return [Symbol, String] name The name of the param the rule applies to. 41 | # @api public 42 | attr_reader :name 43 | 44 | # @return [Hash] options The rule options. 45 | # @option options [Symbol] :in A list of acceptable values. 46 | # @option options [Symbol] :options A list of acceptable values. 47 | # @option options [Symbol] :default The default value of the param. 48 | # @option options [Symbol] :min_value The minimum acceptable value. 49 | # @option options [Symbol] :max_value The maximum acceptable value. 50 | # @option options [Symbol] :min_length The minimum acceptable string length. 51 | # @option options [Symbol] :max_length The maximum acceptable string length. 52 | # @option options [Boolean] :null Can this value be null? 53 | # @option options [Symbol] :doc Documentation for the param. 54 | # @api public 55 | attr_reader :options 56 | 57 | # @param [Symbol, String] name 58 | # The param's name 59 | # @param [Hash] opts The rule options 60 | # @option opts [Symbol] :in A list of acceptable values. 61 | # @option opts [Symbol] :options A list of acceptable values. 62 | # @option opts [Symbol] :default The default value of the param. 63 | # @option opts [Symbol] :min_value The minimum acceptable value. 64 | # @option opts [Symbol] :max_value The maximum acceptable value. 65 | # @option opts [Symbol] :min_length The minimum acceptable string length. 66 | # @option opts [Symbol] :max_length The maximum acceptable string length. 67 | # @option opts [Boolean] :null Can this value be null? 68 | # @option opts [Symbol] :doc Documentation for the param. 69 | # @api public 70 | def initialize(name, opts = {}) 71 | @name = name 72 | @options = opts 73 | end 74 | 75 | # The namespace used if any 76 | # 77 | # @return [NilClass, WeaselDiesel::Params::Namespace] 78 | # @api public 79 | def namespace 80 | @options[:space_name] 81 | end 82 | 83 | # The documentation of this Rule 84 | # 85 | # @return [NilClass, String] 86 | # api public 87 | def doc 88 | @options[:doc] 89 | end 90 | 91 | # Converts the rule into a hash with its name and options. 92 | # 93 | # @return [Hash] 94 | def to_hash 95 | {:name => name, :options => options} 96 | end 97 | 98 | end # of Rule 99 | 100 | # The namespace used if any 101 | # 102 | # @return [NilClass, WeaselDiesel::Params::Namespace] 103 | # @api public 104 | attr_reader :space_name 105 | 106 | # @param [Hash] opts The params options 107 | # @option opts [:symbol] :space_name Optional namespace. 108 | # @api public 109 | def initialize(opts={}) 110 | @space_name = opts[:space_name] 111 | end 112 | 113 | # Defines a new param and add it to the optional or required list based 114 | # the passed options. 115 | # @param [Symbol] type 116 | # The type of param 117 | # 118 | # @param [Symbol, String] name 119 | # The name of the param 120 | # 121 | # @param [Hash] options 122 | # A hash representing the param settings 123 | # 124 | # @example Declaring an integer service param called id 125 | # service.param(:id, :integer, :default => 9999, :in => [0, 9999]) 126 | # 127 | # @return [Array] the typed list of params (required or optional) 128 | # @api public] 129 | def param(type, name, options={}) 130 | options[:type] = type 131 | options[:space_name] = options[:space_name] || space_name 132 | if options.delete(:required) 133 | list_required << Rule.new(name, options) 134 | else 135 | list_optional << Rule.new(name, options) 136 | end 137 | end 138 | 139 | # @group Params defintition DSL (accept_param style) 140 | 141 | # Defines a new string param and add it to the required or optional list 142 | # 143 | # @param [String] name 144 | # The name of the param 145 | # @param [Hash] options 146 | # A hash representing the param settings 147 | # 148 | # @example Defining a string service param named type which has various options. 149 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 150 | # 151 | # @api public 152 | # @return [Arrays] 153 | # List of optional or required param rules depending on the new param rule type 154 | def string(name, options={}) 155 | param(:string, name, options) 156 | end 157 | 158 | # Defines a new integer param and add it to the required or optional list 159 | # 160 | # @param [String] name 161 | # The name of the param 162 | # @param [Hash] options 163 | # A hash representing the param settings 164 | # 165 | # @example Defining a string service param named type which has various options. 166 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 167 | # 168 | # @api public 169 | # @return [Arrays] 170 | # List of optional or required param rules depending on the new param rule type 171 | def integer(name, options={}) 172 | param(:integer, name, options) 173 | end 174 | 175 | # Defines a new float param and add it to the required or optional list 176 | # 177 | # @param [String] name 178 | # The name of the param 179 | # @param [Hash] options 180 | # A hash representing the param settings 181 | # 182 | # @example Defining a string service param named type which has various options. 183 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 184 | # 185 | # @api public 186 | # @return [Arrays] 187 | # List of optional or required param rules depending on the new param rule type 188 | def float(name, options={}) 189 | param(:float, name, options) 190 | end 191 | 192 | # Defines a new decimal param and add it to the required or optional list 193 | # 194 | # @param [String] name 195 | # The name of the param 196 | # @param [Hash] options 197 | # A hash representing the param settings 198 | # 199 | # @example Defining a string service param named type which has various options. 200 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 201 | # 202 | # @api public 203 | # @return [Arrays] 204 | # List of optional or required param rules depending on the new param rule type 205 | def decimal(name, options={}) 206 | param(:decimal, name, options) 207 | end 208 | 209 | # Defines a new boolean param and add it to the required or optional list 210 | # 211 | # @param [String] name 212 | # The name of the param 213 | # @param [Hash] options 214 | # A hash representing the param settings 215 | # 216 | # @example Defining a string service param named type which has various options. 217 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 218 | # 219 | # @api public 220 | # @return [Arrays] 221 | # List of optional or required param rules depending on the new param rule type 222 | def boolean(name, options={}) 223 | param(:boolean, name, options) 224 | end 225 | 226 | # Defines a new datetime param and add it to the required or optional list 227 | # 228 | # @param [String] name 229 | # The name of the param 230 | # @param [Hash] options 231 | # A hash representing the param settings 232 | # 233 | # @example Defining a string service param named type which has various options. 234 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 235 | # 236 | # @api public 237 | # @return [Arrays] 238 | # List of optional or required param rules depending on the new param rule type 239 | def datetime(name, options={}) 240 | param(:datetime, name, options) 241 | end 242 | 243 | # Defines a new text param and add it to the required or optional list 244 | # 245 | # @param [String] name 246 | # The name of the param 247 | # @param [Hash] options 248 | # A hash representing the param settings 249 | # 250 | # @example Defining a string service param named type which has various options. 251 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 252 | # 253 | # @api public 254 | # @return [Arrays] 255 | # List of optional or required param rules depending on the new param rule type 256 | def text(name, options={}) 257 | param(:text, name, options) 258 | end 259 | 260 | # Defines a new binary param and add it to the required or optional list 261 | # 262 | # @param [String] name 263 | # The name of the param 264 | # @param [Hash] options 265 | # A hash representing the param settings 266 | # 267 | # @example Defining a string service param named type which has various options. 268 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 269 | # 270 | # @api public 271 | # @return [Arrays] 272 | # List of optional or required param rules depending on the new param rule type 273 | def binary(name, options={}) 274 | param(:binary, name, options) 275 | end 276 | 277 | # Defines a new array param and add it to the required or optional list 278 | # 279 | # @param [String] name 280 | # The name of the param 281 | # @param [Hash] options 282 | # A hash representing the param settings 283 | # 284 | # @example Defining a string service param named type which has various options. 285 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 286 | # 287 | # @api public 288 | # @return [Array] 289 | # List of optional or required param rules depending on the new param rule type 290 | def array(name, options={}) 291 | param(:array, name, options) 292 | end 293 | 294 | # Defines a new file param and add it to the required or optional list 295 | # 296 | # @param [String] name 297 | # The name of the param 298 | # @param [Hash] options 299 | # A hash representing the param settings 300 | # 301 | # @example Defining a string service param named type which has various options. 302 | # service.param.string :type, :in => LeaderboardType.names, :default => LeaderboardType::LIFETIME 303 | # 304 | # @api public 305 | # @return [Arrays] 306 | # List of optional or required param rules depending on the new param rule type 307 | def file(name, options={}) 308 | param(:file, name, options) 309 | end 310 | 311 | # @group param setters based on the state (required or optional) 312 | 313 | # Defines a new required param 314 | # 315 | # @param [Symbol, String] param_name 316 | # The name of the param to define 317 | # @param [Hash] opts 318 | # A hash representing the required param, the key being the param name name 319 | # and the value being a hash of options. 320 | # 321 | # @example Defining a required service param called 'id' of `Integer` type 322 | # service.params.required :id, :type => 'integer', :default => 9999 323 | # 324 | # @return [Array] The list of required rules 325 | # 326 | # @api public 327 | def required(param_name, opts={}) 328 | # # support for when a required param doesn't have any options 329 | # unless opts.respond_to?(:each_pair) 330 | # opts = {opts => nil} 331 | # end 332 | # # recursive rule creation 333 | # if opts.size > 1 334 | # opts.each_pair{|k,v| requires({k => v})} 335 | # else 336 | list_required << Rule.new(param_name, opts) 337 | # end 338 | end 339 | 340 | # Defines a new optional param rule 341 | # 342 | # @param [Symbol, String] param_name 343 | # The name of the param to define 344 | # @param [Hash] opts 345 | # A hash representing the required param, the key being the param name name 346 | # and the value being a hash of options. 347 | # 348 | # @example Defining an optional service param called 'id' of `Integer` type 349 | # service.params.optional :id, :type => 'integer', :default => 9999 350 | # 351 | # @return [Array] The list of optional rules 352 | # @api public 353 | def optional(param_name, opts={}) 354 | # # recursive rule creation 355 | # if opts.size > 1 356 | # opts.each_pair{|k,v| optional({k => v})} 357 | # else 358 | list_optional << Rule.new(param_name, opts) 359 | # end 360 | end 361 | 362 | # @group params accessors per status (required or optional) 363 | 364 | # Returns an array of all the required params 365 | # 366 | # @return [Array] The list of required rules 367 | # @api public 368 | def list_required 369 | @required ||= [] 370 | end 371 | 372 | # Returns an array of all the optional params 373 | # 374 | # @return [Array] all the optional params 375 | # @api public 376 | def list_optional 377 | @optional ||= [] 378 | end 379 | 380 | # @endgroup 381 | 382 | # Defines a namespaced param 383 | # 384 | # @param [Symbol, String] name 385 | # The name of the namespace 386 | # @param [Hash] opts 387 | # A hash representing the namespace settings 388 | # 389 | # @yield [Params] the newly created namespaced param 390 | # @return [Array] the list of all the namespaced params 391 | # @api public 392 | def namespace(name, opts={}) 393 | params = Params.new(:space_name => Namespace.new(name, :null => opts[:null])) 394 | yield(params) if block_given? 395 | namespaced_params << params unless namespaced_params.include?(params) 396 | end 397 | alias :object :namespace 398 | 399 | # Returns the namespaced params 400 | # 401 | # @return [Array] the list of all the namespaced params 402 | # @api public 403 | def namespaced_params 404 | @namespaced_params ||= [] 405 | end 406 | 407 | # Returns the names of the first level expected params 408 | # 409 | # @return [Array] 410 | # @api public 411 | def param_names 412 | first_level_expected_params = (list_required + list_optional).map{|rule| rule.name.to_s} 413 | first_level_expected_params += namespaced_params.map{|r| r.space_name.name.to_s} 414 | first_level_expected_params 415 | end 416 | 417 | end # of Params 418 | 419 | end 420 | -------------------------------------------------------------------------------- /lib/params_verification.rb: -------------------------------------------------------------------------------- 1 | require 'erb' # used to sanitize the error message and avoid XSS attacks 2 | 3 | # ParamsVerification module. 4 | # Written to verify a service params without creating new objects. 5 | # This module is used on all requests requiring validation and therefore performance 6 | # security and maintainability are critical. 7 | # 8 | # @api public 9 | module ParamsVerification 10 | 11 | class ParamError < StandardError; end #:nodoc 12 | class NoParamsDefined < ParamError; end #:nodoc 13 | class MissingParam < ParamError; end #:nodoc 14 | class UnexpectedParam < ParamError; end #:nodoc 15 | class InvalidParamType < ParamError; end #:nodoc 16 | class InvalidParamValue < ParamError; end #:nodoc 17 | 18 | # An array of validation regular expressions. 19 | # The array gets cached but can be accessed via the symbol key. 20 | # 21 | # @return [Hash] An array with all the validation types as keys and regexps as values. 22 | # @api public 23 | def self.type_validations 24 | @type_validations ||= { :integer => /^-?\d+$/, 25 | :float => /^-?(\d*\.\d+|\d+)$/, 26 | :decimal => /^-?(\d*\.\d+|\d+)$/, 27 | :datetime => /^[-\d:T\s\+]+[zZ]*$/, # "T" is for ISO date format 28 | :boolean => /^(1|true|TRUE|T|Y|0|false|FALSE|F|N)$/, 29 | #:array => /,/ 30 | } 31 | end 32 | 33 | # Validation against each required WeaselDiesel::Params::Rule 34 | # and returns the potentially modified params (with default values) 35 | # 36 | # @param [Hash] params The params to verify (incoming request params) 37 | # @param [WeaselDiesel::Params] service_params A Playco service param compatible object listing required and optional params 38 | # @param [Boolean] ignore_unexpected Flag letting the validation know if unexpected params should be ignored 39 | # 40 | # @return [Hash] 41 | # The passed params potentially modified by the default rules defined in the service. 42 | # 43 | # @example Validate request params against a service's defined param rules 44 | # ParamsVerification.validate!(request.params, @service.defined_params) 45 | # 46 | # @api public 47 | def self.validate!(params, service_params, ignore_unexpected=false) 48 | 49 | # Verify that no garbage params are passed, if they are, an exception is raised. 50 | # only the first level is checked at this point 51 | unless ignore_unexpected 52 | unexpected_params?(params, service_params.param_names) 53 | end 54 | 55 | # dupe the params so we don't modify the passed value 56 | updated_params = params.dup 57 | # Required param verification 58 | service_params.list_required.each do |rule| 59 | updated_params = validate_required_rule(rule, updated_params) 60 | end 61 | 62 | # Set optional defaults if any optional 63 | service_params.list_optional.each do |rule| 64 | updated_params = validate_optional_rule(rule, updated_params) 65 | end 66 | 67 | # check the namespaced params 68 | service_params.namespaced_params.each do |param| 69 | unless param.space_name.null && updated_params[param.space_name.name.to_s].nil? 70 | param.list_required.each do |rule| 71 | updated_params = validate_required_rule(rule, updated_params, param.space_name.name.to_s) 72 | end 73 | param.list_optional.each do |rule| 74 | updated_params = validate_optional_rule(rule, updated_params, param.space_name.name.to_s) 75 | end 76 | end 77 | end 78 | 79 | # verify nested params, only 1 level deep tho 80 | params.each_pair do |key, value| 81 | if value.is_a?(Hash) 82 | namespaced = service_params.namespaced_params.find{|np| np.space_name.name.to_s == key.to_s} 83 | raise UnexpectedParam, "Request included unexpected parameter: #{ERB::Util.html_escape(key)}" if namespaced.nil? 84 | unexpected_params?(params[key], namespaced.param_names) 85 | end 86 | end 87 | 88 | updated_params 89 | end 90 | 91 | 92 | private 93 | 94 | # Validates a required rule against a list of params passed. 95 | # 96 | # 97 | # @param [WeaselDiesel::Params::Rule] rule The required rule to check against. 98 | # @param [Hash] params The request params. 99 | # @param [String] namespace Optional param namespace to check the rule against. 100 | # 101 | # @return [Hash] 102 | # A hash representing the potentially modified params after going through the filter. 103 | # 104 | # @api private 105 | def self.validate_required_rule(rule, params, namespace=nil) 106 | param_name = rule.name.to_s 107 | param_value, namespaced_params = extract_param_values(params, param_name, namespace) 108 | 109 | # Checks presence 110 | if !(namespaced_params || params).keys.include?(param_name) 111 | raise MissingParam, "'#{rule.name}' is missing - passed params: #{html_escape(params.inspect)}." 112 | end 113 | 114 | updated_param_value, updated_params = validate_and_cast_type(param_value, param_name, rule.options[:type], params, namespace) 115 | 116 | # check for nulls in params that don't allow them 117 | if !valid_null_param?(param_name, updated_param_value, rule) 118 | raise InvalidParamValue, "Value for parameter '#{param_name}' cannot be null - passed params: #{html_escape(updated_params.inspect)}." 119 | elsif updated_param_value 120 | value_errors = validate_ruled_param_value(param_name, updated_param_value, rule) 121 | raise InvalidParamValue, value_errors.join(', ') if value_errors 122 | end 123 | 124 | updated_params 125 | end 126 | 127 | 128 | # Validates that an optional rule is respected. 129 | # If the rule contains default values, the params might be updated. 130 | # 131 | # @param [#WeaselDiesel::Params::Rule] rule The optional rule 132 | # @param [Hash] params The request params 133 | # @param [String] namespace An optional namespace 134 | # 135 | # @return [Hash] The potentially modified params 136 | # 137 | # @api private 138 | def self.validate_optional_rule(rule, params, namespace=nil) 139 | param_name = rule.name.to_s 140 | param_value, namespaced_params = extract_param_values(params, param_name, namespace) 141 | 142 | if param_value && !valid_null_param?(param_name, param_value, rule) 143 | raise InvalidParamValue, "Value for parameter '#{param_name}' cannot be null if passed - passed params: #{html_escape(params.inspect)}." 144 | end 145 | 146 | # Use a default value if one is available and the submitted param value is nil 147 | if param_value.nil? && rule.options[:default] 148 | param_value = rule.options[:default] 149 | if namespace 150 | params[namespace] ||= {} 151 | params[namespace][param_name] = param_value 152 | else 153 | params[param_name] = param_value 154 | end 155 | end 156 | 157 | updated_param_value, updated_params = validate_and_cast_type(param_value, param_name, rule.options[:type], params, namespace) 158 | value_errors = validate_ruled_param_value(param_name, updated_param_value, rule) if updated_param_value 159 | raise InvalidParamValue, value_errors.join(', ') if value_errors 160 | 161 | updated_params 162 | end 163 | 164 | 165 | # Validates the param value against the rule and cast the param in the appropriate type. 166 | # The modified params containing the cast value is returned along the cast param value. 167 | # 168 | # @param [Object] param_value The value to validate and cast. 169 | # @param [String] param_name The name of the param we are validating. 170 | # @param [Symbol] rule_type The expected object type. 171 | # @param [Hash] params The params that might need to be updated. 172 | # @param [String, Symbol] namespace The optional namespace used to access the `param_value` 173 | # 174 | # @return [Array] An array containing the param value and 175 | # a hash representing the potentially modified params after going through the filter. 176 | # 177 | def self.validate_and_cast_type(param_value, param_name, rule_type, params, namespace=nil) 178 | # checks type & modifies params if needed 179 | if rule_type && param_value 180 | # nullify empty strings for any types other than string 181 | param_value = nil if param_value == '' && rule_type != :string 182 | verify_cast(param_name, param_value, rule_type) 183 | param_value = type_cast_value(rule_type, param_value) 184 | # update the params hash with the type cast value 185 | if namespace 186 | params[namespace] ||= {} 187 | params[namespace][param_name] = param_value 188 | else 189 | params[param_name] = param_value 190 | end 191 | end 192 | [param_value, params] 193 | end 194 | 195 | 196 | # Validates a value against a few rule options. 197 | # 198 | # @return [NilClass, Array] Returns an array of error messages if an option didn't validate. 199 | def self.validate_ruled_param_value(param_name, param_value, rule) 200 | 201 | # checks the value against a whitelist style 'in'/'options' list 202 | if rule.options[:options] || rule.options[:in] 203 | choices = rule.options[:options] || rule.options[:in] 204 | unless param_value.is_a?(Array) ? (param_value & choices == param_value) : choices.include?(param_value) 205 | errors ||= [] 206 | errors << "Value for parameter '#{param_name}' (#{html_escape(param_value)}) is not in the allowed set of values." 207 | end 208 | end 209 | 210 | # enforces a minimum numeric value 211 | if rule.options[:min_value] 212 | min = rule.options[:min_value] 213 | if param_value.to_i < min 214 | errors ||= [] 215 | errors << "Value for parameter '#{param_name}' ('#{html_escape(param_value)}') is lower than the min accepted value (#{min})." 216 | end 217 | end 218 | 219 | # enforces a maximum numeric value 220 | if rule.options[:max_value] 221 | max = rule.options[:max_value] 222 | if param_value.to_i > max 223 | errors ||= [] 224 | errors << "Value for parameter '#{param_name}' ('#{html_escape(param_value)}') is higher than the max accepted value (#{max})." 225 | end 226 | end 227 | 228 | # enforces a minimum string length 229 | if rule.options[:min_length] 230 | min = rule.options[:min_length] 231 | if param_value.to_s.length < min 232 | errors ||= [] 233 | errors << "Length of parameter '#{param_name}' ('#{html_escape(param_value)}') is shorter than the min accepted value (#{min})." 234 | end 235 | end 236 | 237 | # enforces a maximum string length 238 | if rule.options[:max_length] 239 | max = rule.options[:max_length] 240 | if param_value.to_s.length > max 241 | errors ||= [] 242 | errors << "Length of parameter '#{param_name}' ('#{html_escape(param_value)}') is longer than the max accepted value (#{max})." 243 | end 244 | end 245 | 246 | errors 247 | end 248 | 249 | # Extract the param value and the namespaced params 250 | # based on a passed namespace and params 251 | # 252 | # @param [Hash] params The passed params to extract info from. 253 | # @param [String] param_name The param name to find the value. 254 | # @param [NilClass, String] namespace the params' namespace. 255 | # @return [Array] 256 | # 257 | # @api private 258 | def self.extract_param_values(params, param_name, namespace=nil) 259 | # Namespace check 260 | if namespace == '' || namespace.nil? 261 | [params[param_name], nil] 262 | else 263 | # puts "namespace: #{namespace} - params #{params[namespace].inspect}" 264 | namespaced_params = params[namespace] 265 | if namespaced_params 266 | [namespaced_params[param_name], namespaced_params] 267 | else 268 | [nil, namespaced_params] 269 | end 270 | end 271 | end 272 | 273 | 274 | def self.unexpected_params?(params, param_names) 275 | # Raise an exception unless no unexpected params were found 276 | unexpected_keys = (params.keys - param_names) 277 | unless unexpected_keys.empty? 278 | raise UnexpectedParam, "Request included unexpected parameter(s): #{unexpected_keys.map{|k| ERB::Util.html_escape(k)}.join(', ')}" 279 | end 280 | end 281 | 282 | 283 | def self.type_cast_value(type, value) 284 | return value if value == nil 285 | case type 286 | when :integer 287 | value.to_i 288 | when :float, :decimal 289 | value.to_f 290 | when :string 291 | value.to_s 292 | when :boolean 293 | if value.is_a? TrueClass 294 | true 295 | elsif value.is_a? FalseClass 296 | false 297 | else 298 | case value.to_s 299 | when /^(1|true|TRUE|T|Y)$/ 300 | true 301 | when /^(0|false|FALSE|F|N)$/ 302 | false 303 | else 304 | raise InvalidParamValue, "Could not typecast boolean to appropriate value" 305 | end 306 | end 307 | # An array type is a comma delimited string, we need to cast the passed strings. 308 | when :array 309 | value.respond_to?(:split) ? value.split(',') : value 310 | when :binary, :file 311 | value 312 | else 313 | value 314 | end 315 | end 316 | 317 | # Checks that the value's type matches the expected type for a given param. If a nil value is passed 318 | # the verification is skipped. 319 | # 320 | # @param [Symbol, String] Param name used if the verification fails and that an error is raised. 321 | # @param [NilClass, #to_s] The value to validate. 322 | # @param [Symbol] The expected type, such as :boolean, :integer etc... 323 | # @raise [InvalidParamType] Custom exception raised when the validation isn't found or the value doesn't match. 324 | # 325 | # @return [NilClass] 326 | # @api public 327 | def self.verify_cast(name, value, expected_type) 328 | return if value == nil 329 | validation = ParamsVerification.type_validations[expected_type.to_sym] 330 | unless validation.nil? || value.to_s =~ validation 331 | raise InvalidParamType, "Value for parameter '#{name}' (#{html_escape(value)}) is of the wrong type (expected #{expected_type})" 332 | end 333 | end 334 | 335 | # Checks that a param explicitly set to not be null is present. 336 | # if 'null' is found in the ruleset and set to 'false' (default is 'true' to allow null), 337 | # then confirm that the submitted value isn't nil or empty 338 | # @param [String] param_name The name of the param to verify. 339 | # @param [NilClass, String, Integer, TrueClass, FalseClass] param_value The value to check. 340 | # @param [WeaselDiesel::Params::Rule] rule The rule to check against. 341 | # 342 | # @return [Boolean] true if the param is valid, false otherwise 343 | def self.valid_null_param?(param_name, param_value, rule) 344 | if rule.options.has_key?(:null) && rule.options[:null] == false 345 | if rule.options[:type] && rule.options[:type] == :array 346 | return false if param_value.nil? || (param_value.respond_to?(:split) && param_value.split(',').empty?) 347 | else 348 | return false if param_value.nil? || param_value == '' 349 | end 350 | end 351 | true 352 | end 353 | 354 | def self.html_escape(msg) 355 | ERB::Util.html_escape(msg) 356 | end 357 | 358 | end 359 | -------------------------------------------------------------------------------- /lib/response.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class WeaselDiesel 4 | # Response DSL class 5 | # @api public 6 | class Response 7 | 8 | # The list of all the elements inside the response 9 | # 10 | # @return [Array] 11 | # @api public 12 | attr_reader :elements 13 | 14 | # The list of all the arays inside the response 15 | # 16 | # @return [Array] 17 | attr_reader :arrays 18 | 19 | def initialize 20 | @elements = [] 21 | @arrays = [] 22 | end 23 | 24 | # Lists all top level simple elements and array elements. 25 | # 26 | # @return [Array] 27 | def nodes 28 | elements + arrays 29 | end 30 | 31 | # Shortcut to automatically create a node of array type. 32 | # Useful when describing a JSON response. 33 | # 34 | # @param [String, Symbol] name the name of the element. 35 | # @param [Hash] opts the element options. 36 | # @see Vector#initialize 37 | def array(name=nil, type=nil) 38 | vector = Vector.new(name, type) 39 | yield(vector) if block_given? 40 | @arrays << vector 41 | end 42 | 43 | # Defines a new element and yields the content of an optional block 44 | # Each new element is then stored in the elements array. 45 | # 46 | # @param [Hash] opts Options used to define the element 47 | # @option opts [String, Symbol] :name The element name 48 | # @option opts [String, Symbol] :type The optional type 49 | # 50 | # @yield [WeaselDiesel::Response::Element] the newly created element 51 | # @example create an element called 'my_stats'. 52 | # service.response do |response| 53 | # response.element(:name => "my_stats", :type => 'Leaderboard') 54 | # end 55 | # 56 | # @return [WeaselDiesel::Response::Element] 57 | # @api public 58 | def element(opts={}) 59 | el = Element.new(opts[:name], opts[:type]) 60 | yield(el) if block_given? 61 | @elements << el 62 | el 63 | end 64 | 65 | # Defines an element/object in a consistent way with 66 | # the way objects are defined in nested objects. 67 | # @param [Symbol, String] name the name of the element. 68 | # @param [Hash] opts the options for the newly created element. 69 | # @return [WeaselDiesel::Response] returns self since it yields to the used block. 70 | def object(name=nil, opts={}) 71 | yield element(opts.merge(:name => name)) 72 | end 73 | 74 | # Returns a response element object based on its name 75 | # @param [String, Symbol] The element name we want to match 76 | # 77 | # @return [WeaselDiesel::Response::Element] 78 | # @api public 79 | def element_named(name) 80 | @elements.find{|e| e.name.to_s == name.to_s} 81 | end 82 | 83 | 84 | # Converts the object into a JSON representation 85 | # @return [String] JSON representation of the response 86 | def to_json(*args) 87 | if nodes.size > 1 88 | nodes.to_json(*args) 89 | else 90 | nodes.first.to_json(*args) 91 | end 92 | end 93 | 94 | 95 | class Params 96 | class Rule 97 | def to_hash 98 | {:name => name, :options => options} 99 | end 100 | end 101 | end 102 | 103 | # The Response element class describing each element of a service response. 104 | # Instances are usually not instantiated directly but via the Response#element accessor. 105 | # 106 | # @see WeaselDiesel::Response#element 107 | # @api public 108 | class Element 109 | 110 | # @return [String, #to_s] The name of the element 111 | # @api public 112 | attr_reader :name 113 | 114 | # @api public 115 | attr_reader :type 116 | 117 | # The optional lookup key of an object 118 | attr_reader :key 119 | 120 | # @return [Array] An array of attributes 121 | # @api public 122 | attr_reader :attributes 123 | 124 | # @return [Array] An array of meta attributes 125 | # @api public 126 | attr_reader :meta_attributes 127 | 128 | # @return [Array] An array of vectors/arrays 129 | # @api public 130 | attr_reader :vectors 131 | 132 | # @return [WeaselDiesel::Documentation::ElementDoc] Response element documentation 133 | # @api public 134 | attr_reader :doc 135 | 136 | # @return [NilClass, Array] The optional nested elements 137 | attr_reader :elements 138 | 139 | # Alias to use a JSON/JS jargon instead of XML. 140 | alias :properties :attributes 141 | 142 | # Alias to use a JSON/JS jargon instead of XML. 143 | alias :objects :elements 144 | 145 | # param [String, Symbol] name The name of the element 146 | # param [String, Symbol] type The optional type of the element 147 | # @api public 148 | def initialize(name, type=nil) 149 | # sets a documentation placeholder since the response doc is defined at the same time 150 | # the response is defined. 151 | @doc = Documentation::ElementDoc.new(name) 152 | @name = name 153 | @type = type 154 | @attributes = [] 155 | @meta_attributes = [] 156 | @elements = [] 157 | @vectors = [] 158 | @key = nil 159 | # we don't need to initialize the nested elements, by default they should be nil 160 | end 161 | 162 | # sets a new attribute and returns the entire list of attributes 163 | # 164 | # @param [Hash] opts An element's attribute options 165 | # @option opts [String, Symbol] attribute_name The name of the attribute, the value being the type 166 | # @option opts [String, Symbol] :doc The attribute documentation 167 | # @option opts [String, Symbol] :mock An optional mock value used by service related tools 168 | # 169 | # @example Creation of a response attribute called 'best_lap_time' 170 | # service.response do |response| 171 | # response.element(:name => "my_stats", :type => 'Leaderboard') do |e| 172 | # e.attribute "best_lap_time" => :float, :doc => "Best lap time in seconds." 173 | # end 174 | # end 175 | # 176 | # @return [Array] 177 | # @api public 178 | def attribute(opts) 179 | raise ArgumentError unless opts.is_a?(Hash) 180 | new_attribute = Attribute.new(opts) 181 | @attributes << new_attribute 182 | # document the attribute if description available 183 | # we might want to have a placeholder message when a response attribute isn't defined 184 | if opts.has_key?(:doc) 185 | @doc.attribute(new_attribute.name, opts[:doc]) 186 | end 187 | @attributes 188 | end 189 | 190 | # sets a new meta attribute and returns the entire list of meta attributes 191 | # 192 | # @param [Hash] opts An element's attribute options 193 | # @option opts [String, Symbol] attribute_name The name of the attribute, the value being the type 194 | # @option opts [String, Symbol] :mock An optional mock value used by service related tools 195 | # 196 | # @example Creation of a response attribute called 'best_lap_time' 197 | # service.response do |response| 198 | # response.element(:name => "my_stats", :type => 'Leaderboard') do |e| 199 | # e.meta_attribute "id" => :key 200 | # end 201 | # end 202 | # 203 | # @return [Array] 204 | # @api public 205 | def meta_attribute(opts) 206 | raise ArgumentError unless opts.is_a?(Hash) 207 | # extract the documentation part and add it where it belongs 208 | new_attribute = MetaAttribute.new(opts) 209 | @meta_attributes << new_attribute 210 | @meta_attributes 211 | end 212 | 213 | # Defines an array aka vector of elements. 214 | # 215 | # @param [String, Symbol] name The name of the array element. 216 | # @param [String, Symbol] type Optional type information, useful to store the represented 217 | # object types for instance. 218 | # 219 | # @param [Proc] &block 220 | # A block to execute against the newly created array. 221 | # 222 | # @example Defining an element array called 'player_creation_rating' 223 | # element.array 'player_creation_rating', 'PlayerCreationRating' do |a| 224 | # a.attribute :comments => :string 225 | # a.attribute :player_id => :integer 226 | # a.attribute :rating => :integer 227 | # a.attribute :username => :string 228 | # end 229 | # @yield [Vector] the newly created array/vector instance 230 | # @see Element#initialize 231 | # 232 | # @return [Array] 233 | # @api public 234 | def array(name, type=nil) 235 | vector = Vector.new(name, type) 236 | yield(vector) if block_given? 237 | @vectors << vector 238 | end 239 | 240 | # Returns the arrays/vectors contained in the response. 241 | # This is an alias to access @vectors 242 | # @see @vectors 243 | # 244 | # @return [Array] 245 | # @api public 246 | def arrays 247 | @vectors 248 | end 249 | 250 | # Defines a new element and yields the content of an optional block 251 | # Each new element is then stored in the elements array. 252 | # 253 | # @param [Hash] opts Options used to define the element 254 | # @option opts [String, Symbol] :name The element name 255 | # @option opts [String, Symbol] :type The optional type 256 | # 257 | # @yield [WeaselDiesel::Response::Element] the newly created element 258 | # @example create an element called 'my_stats'. 259 | # service.response do |response| 260 | # response.element(:name => "my_stats", :type => 'Leaderboard') 261 | # end 262 | # 263 | # @return [Array] 264 | # @api public 265 | def element(opts={}) 266 | el = Element.new(opts[:name], opts[:type]) 267 | yield(el) if block_given? 268 | @elements ||= [] 269 | @elements << el 270 | el 271 | end 272 | 273 | # Shortcut to create a new element. 274 | # 275 | # @param [Symbol, String] name the name of the element. 276 | # @param [Hash] opts the options for the newly created element. 277 | def object(name=nil, opts={}, &block) 278 | element(opts.merge(:name => name), &block) 279 | end 280 | 281 | # Getter/setter for the key meta attribute. 282 | # A key name can be used to lookup an object by a primary key for instance. 283 | # 284 | # @param [Symbol, String] name the name of the key attribute. 285 | # @param [Hash] opts the options attached with the key. 286 | def key(name=nil, opts={}) 287 | meta_attribute_getter_setter(:key, name, opts) 288 | end 289 | 290 | # Getter/setter for the type meta attribute. 291 | # 292 | # @param [Symbol, String] name the name of the type attribute. 293 | # @param [Hash] opts the options attached with the key. 294 | def type(name=nil, opts={}) 295 | meta_attribute_getter_setter(:type, name, opts) 296 | end 297 | 298 | # Shortcut to create a string attribute 299 | # 300 | # @param [Symbol, String] name the name of the attribute. 301 | # @param [Hash] opts the attribute options. 302 | def string(name=nil, opts={}) 303 | attribute({name => :string}.merge(opts)) 304 | end 305 | 306 | # Shortcut to create a string attribute 307 | # 308 | # @param [Symbol, String] name the name of the attribute. 309 | # @param [Hash] opts the attribute options. 310 | def integer(name=nil, opts={}) 311 | attribute({name => :integer}.merge(opts)) 312 | end 313 | 314 | # Shortcut to create a string attribute 315 | # 316 | # @param [Symbol, String] name the name of the attribute. 317 | # @param [Hash] opts the attribute options. 318 | def float(name=nil, opts={}) 319 | attribute({name => :float}.merge(opts)) 320 | end 321 | 322 | # Shortcut to create a string attribute 323 | # 324 | # @param [Symbol, String] name the name of the attribute. 325 | # @param [Hash] opts the attribute options. 326 | def boolean(name=nil, opts={}) 327 | attribute({name => :boolean}.merge(opts)) 328 | end 329 | 330 | # Shortcut to create a string attribute 331 | # 332 | # @param [Symbol, String] name the name of the attribute. 333 | # @param [Hash] opts the attribute options. 334 | def datetime(name=nil, opts={}) 335 | attribute({name => :datetime}.merge(opts)) 336 | end 337 | 338 | # Converts an element into a hash representation 339 | # 340 | # @param [Boolean] root_node true if this node has no parents. 341 | # @return [Hash] the element attributes formated in a hash 342 | def to_hash(root_node=true) 343 | attrs = {} 344 | attributes.each{ |attr| attrs[attr.name] = attr.type } 345 | (vectors + elements).each{ |el| attrs[el.name] = el.to_hash(false) } 346 | if self.class == Vector 347 | (root_node && name) ? {name => [attrs]} : [attrs] 348 | else 349 | (root_node && name) ? {name => attrs} : attrs 350 | end 351 | end 352 | 353 | # Converts an element into a json representation 354 | # 355 | # @return [String] the element attributes formated in a json structure 356 | def to_json 357 | to_hash.to_json 358 | end 359 | 360 | def to_html 361 | output = "" 362 | if name 363 | output << "
  • " 364 | output << "#{name} of type #{self.is_a?(Vector) ? 'Array' : 'Object'}" 365 | end 366 | if self.is_a? Vector 367 | output << "
    Properties of each array item:
    " 368 | else 369 | output << "
    Properties:
    " 370 | end 371 | output << "
      " 372 | properties.each do |prop| 373 | output << "
    • #{prop.name} of type #{prop.type} #{'(Can be blank or missing) ' if prop.opts && prop.opts.respond_to?(:[]) && prop.opts[:null]} " 374 | output << prop.doc unless prop.doc.nil? or prop.doc.empty? 375 | output << "
    • " 376 | end 377 | arrays.each{ |arr| output << arr.to_html } 378 | elements.each {|el| output << el.to_html } if elements 379 | output << "
    " 380 | output << "
  • " if name 381 | output 382 | end 383 | 384 | private 385 | 386 | # Create a meta element attribute 387 | def meta_attribute_getter_setter(type, name, opts) 388 | if name 389 | meta_attribute({name => type}.merge(opts)) 390 | else 391 | # with a fallback to the @type ivar 392 | meta = meta_attributes.find{|att| att.type == type} 393 | if meta 394 | meta.value 395 | else 396 | instance_variable_get("@#{type}") 397 | end 398 | end 399 | end 400 | 401 | # Response element's attribute class 402 | # @api public 403 | class Attribute 404 | 405 | # @return [String, #to_s] The attribute's name. 406 | # @api public 407 | attr_reader :name 408 | alias :value :name 409 | 410 | # @return [Symbol, String, #to_s] The attribute's type such as boolean, string etc.. 411 | # @api public 412 | attr_reader :type 413 | 414 | # @return [String] The documentation associated with this attribute. 415 | # @api public 416 | attr_reader :doc 417 | 418 | # @see {Attribute#new} 419 | # @return [Hash, Nil, Object] Could be a hash, nil or any object depending on how the attribute is created. 420 | # @api public 421 | attr_reader :opts 422 | 423 | # Takes a Hash or an Array and extract the attribute name, type 424 | # doc and extra options. 425 | # If the passed objects is a Hash, the name will be extract from 426 | # the first key and the type for the first value. 427 | # An entry keyed by :doc will be used for the doc and the rest will go 428 | # as extra options. 429 | # 430 | # If an Array is passed, the elements will be 'shifted' in this order: 431 | # name, type, doc, type 432 | # 433 | # @param [Hash, Array] o_params 434 | # 435 | # @api public 436 | def initialize(o_params) 437 | params = o_params.dup 438 | if params.is_a?(Hash) 439 | @name, @type = params.shift 440 | @doc = params.delete(:doc) if params.has_key?(:doc) 441 | @opts = params 442 | elsif params.is_a?(Array) 443 | @name = params.shift 444 | @type = params.shift 445 | @doc = params.shift 446 | @opts = params 447 | end 448 | end 449 | end 450 | 451 | # Response's meta attribute meant to set some extra 452 | # attributes which are not part of the response per se. 453 | class MetaAttribute < Attribute 454 | end 455 | 456 | end # of Element 457 | 458 | # Array of objects 459 | # @api public 460 | class Vector < Element 461 | end # of Vector 462 | 463 | end # of Response 464 | end 465 | -------------------------------------------------------------------------------- /lib/weasel_diesel.rb: -------------------------------------------------------------------------------- 1 | require_relative 'params' 2 | require_relative 'response' 3 | require_relative 'documentation' 4 | require_relative 'ws_list' 5 | require 'weasel_diesel/dsl' 6 | 7 | # WeaselDiesel offers a web service DSL to define web services, 8 | # their params, http verbs, formats expected as well as the documentation 9 | # for all these aspects of a web service. 10 | # 11 | # This DSL is only meant to describe a web service and isn't meant to cover any type 12 | # of implementation details. It is meant to be framework/tool agnostic. 13 | # 14 | # However, tools can be built around the Web Service DSL data structure to extract documentation, 15 | # generate routing information, verify that an incoming request is valid, generate automated tests... 16 | # 17 | # 18 | # 19 | # WeaselDiesel 20 | # | 21 | # |__ service options (name, url, SSL, auth required formats, verbs, controller name, action, version, extra) 22 | # |__ defined_params (instance of WeaselDiesel::Params) 23 | # | | | |_ Optional param rules 24 | # | | |_ Required param rules 25 | # | |_ Namespaced params (array containing nested optional and required rules) 26 | # |__ response (instance of WeaselDiesel::Response) 27 | # | |_ elements (array of elements with each element having a name, type, attributes and vectors 28 | # | | | |_ attributes (array of WeaselDiesel::Response::Attribute, each attribute has a name, a type, a doc and some extra options) 29 | # | | |_ vectors (array of WeaselDiesel::Response::Vector), each vector has a name, obj_type, & an array of attributes 30 | # | | |_ attributes (array of WeaselDiesel::Response::Attribute, each attribute has a name, a type and a doc) 31 | # | |_ arrays (like elements but represent an array of objects) 32 | # | 33 | # |__ doc (instance of WeaselDiesel::Documentation) 34 | # | | | |_ overal) description 35 | # | | |_ examples (array of examples as strings) 36 | # | |_ params documentation (Hash with the key being the param name and the value being the param documentation) 37 | # |_ response (instance of Documentation.new) 38 | # |_ elements (array of instances of WeaselDiesel::Documentation::ElementDoc, each element has a name and a list of attributes) 39 | # |_ attributes (Hash with the key being the attribute name and the value being the attribute's documentation) 40 | # 41 | # @since 0.0.3 42 | # @api public 43 | class WeaselDiesel 44 | 45 | # Returns the service url 46 | # 47 | # @return [String] The service url 48 | # @api public 49 | attr_reader :url 50 | 51 | # List of all the service params 52 | # 53 | # @return [Array] 54 | # @api public 55 | attr_reader :defined_params 56 | 57 | # Documentation instance containing all the service doc 58 | # 59 | # @return [WeaselDiesel::Documentation] 60 | # @api public 61 | attr_reader :doc 62 | 63 | # The HTTP verb supported 64 | # 65 | # @return [Symbol] 66 | # @api public 67 | attr_reader :verb 68 | 69 | # Service's version 70 | # 71 | # @return [String] 72 | # @api public 73 | attr_reader :version 74 | 75 | # Controller instance associated with the service 76 | # 77 | # @return [WSController] 78 | # @api public 79 | attr_reader :controller 80 | 81 | # Name of the controller action associated with the service 82 | # 83 | # @return [String] 84 | # @api public 85 | attr_accessor :action 86 | 87 | # Name of the controller associated with the service 88 | # 89 | # @return [String] 90 | # @api public 91 | attr_accessor :controller_name 92 | 93 | # Name of the service 94 | # 95 | # @return [String] 96 | # @api public 97 | attr_reader :name 98 | 99 | # Is SSL required? 100 | # 101 | # @return [Boolean] 102 | # @api public 103 | attr_reader :ssl 104 | 105 | # Is authentication required? 106 | # 107 | # @return [Boolean] 108 | # @api public 109 | attr_reader :auth_required 110 | 111 | # Extra placeholder to store data in based on developer's discretion. 112 | # 113 | # @return [Hash] A hash storing extra data based. 114 | # @api public 115 | # @since 0.1 116 | attr_reader :extra 117 | 118 | # Service constructor which is usually used via {Kernel#describe_service} 119 | # 120 | # @param [String] url Service's url ( the url will automatically be prepended a slash if it doesn't already contain one. 121 | # @see #describe_service See how this class is usually initialized using `describe_service` 122 | # @api public 123 | def initialize(url) 124 | @url = url.start_with?('/') ? url : "/#{url}" 125 | @defined_params = WeaselDiesel::Params.new 126 | @doc = WeaselDiesel::Documentation.new 127 | @response = WeaselDiesel::Response.new 128 | @verb = :get 129 | @formats = [] 130 | @version = '0.1' 131 | @ssl = false 132 | @auth_required = true 133 | @extra = {} 134 | end 135 | 136 | # Checks the WeaselDiesel flag to see if the controller names are pluralized. 137 | # 138 | # @return [Boolean] The updated value, default to false 139 | # @api public 140 | # @since 0.1.1 141 | def self.use_pluralized_controllers 142 | @pluralized_controllers ||= false 143 | end 144 | 145 | # Sets a WeaselDiesel global flag so all controller names will be automatically pluralized. 146 | # 147 | # @param [Boolean] True if the controllers are pluralized, False otherwise. 148 | # 149 | # @return [Boolean] The updated value 150 | # @api public 151 | # @since 0.1.1 152 | def self.use_pluralized_controllers=(val) 153 | @pluralized_controllers = val 154 | end 155 | 156 | # Returns the defined params 157 | # for DSL use only! 158 | # To keep the distinction between the request params and the service params 159 | # using the +defined_params+ accessor is recommended. 160 | # @see WeaselDiesel::Params 161 | # 162 | # @return [WeaselDiesel::Params] The defined params 163 | # @api public 164 | def params 165 | if block_given? 166 | yield(@defined_params) 167 | else 168 | @defined_params 169 | end 170 | end 171 | alias :param :params 172 | 173 | # Returns true if the DSL defined any params 174 | # 175 | # @return [Boolean] 176 | def params? 177 | !(required_rules.empty? && optional_rules.empty? && nested_params.empty?) 178 | end 179 | 180 | # Returns an array of required param rules 181 | # 182 | # @return [Array] Only the required param rules 183 | # @api public 184 | def required_rules 185 | @defined_params.list_required 186 | end 187 | 188 | # Returns an array of optional param rules 189 | # 190 | # @return [Array]Only the optional param rules 191 | # @api public 192 | def optional_rules 193 | @defined_params.list_optional 194 | end 195 | 196 | # Returns an array of namespaced params 197 | # @see WeaselDiesel::Params#namespaced_params 198 | # 199 | # @return [Array] the namespaced params 200 | # @api public 201 | def nested_params 202 | @defined_params.namespaced_params 203 | end 204 | 205 | # Mark that the service doesn't require authentication. 206 | # Note: Authentication is turned on by default 207 | # 208 | # @return [Boolean] 209 | # @api public 210 | def disable_auth 211 | @auth_required = false 212 | end 213 | 214 | # Mark that the service requires a SSL connection 215 | # 216 | # @return [Boolean] 217 | # @api public 218 | def enable_ssl 219 | @ssl = true 220 | end 221 | 222 | # Mark the current service as not accepting any params. 223 | # This is purely for expressing the developer's objective since 224 | # by default an error is raise if no params are defined and some 225 | # params are sent. 226 | # 227 | # @return [Nil] 228 | # @api public 229 | def accept_no_params! 230 | # no op operation since this is the default behavior 231 | # unless params get defined. Makes sense for documentation tho. 232 | end 233 | 234 | # Returns the service response 235 | # @yield The service response object 236 | # 237 | # @return [WeaselDiesel::Response] 238 | # @api public 239 | def response 240 | if block_given? 241 | yield(@response) 242 | else 243 | @response 244 | end 245 | end 246 | 247 | # Sets or returns the supported formats 248 | # @param [String, Symbol] f_types Format type supported, such as :xml 249 | # 250 | # @return [Array] List of supported formats 251 | # @api public 252 | def formats(*f_types) 253 | f_types.each{|f| @formats << f unless @formats.include?(f) } 254 | @formats 255 | end 256 | 257 | # Sets the accepted HTTP verbs or return it if nothing is passed. 258 | # 259 | # @return [String, Symbol] 260 | # @api public 261 | def http_verb(s_verb=nil) 262 | return @verb if s_verb.nil? 263 | @verb = s_verb.to_sym 264 | # Depending on the service settings and url, the service action might need to be updated. 265 | # This is how we can support restful routes where a PUT request automatically uses the update method. 266 | update_restful_action(@verb) 267 | @verb 268 | end 269 | 270 | # Yields and returns the documentation object 271 | # @yield [WeaselDiesel::Documentation] 272 | # 273 | # @return [WeaselDiesel::Documentation] The service documentation object 274 | # @api public 275 | def documentation 276 | if block_given? 277 | yield(doc) 278 | else 279 | doc 280 | end 281 | end 282 | 283 | # Assign a route loading point to compare two routes. 284 | # Using this point value, one can load routes with the more globbing 285 | # routes later than short routes. 286 | # 287 | # @return [Integer] point value 288 | def route_loading_point 289 | url =~ /(.*?):(.*?)[\/\.](.*)/ 290 | return url.size if $1.nil? 291 | # The shortest the prepend, the further the service should be loaded 292 | prepend = $1.size 293 | # The shortest the placeholder, the further it should be in the queue 294 | place_holder = $2.size 295 | # The shortest the trail, the further it should be in the queue 296 | trail = $3.size 297 | prepend + place_holder + trail 298 | end 299 | 300 | # Compare two services using the route loading point 301 | def <=> (other) 302 | route_loading_point <=> other.route_loading_point 303 | end 304 | 305 | # Takes input param documentation and copy it over to the document object. 306 | # We need to do that so the params can be both documented when a param is defined 307 | # and in the documentation block. 308 | # @api private 309 | def sync_input_param_doc 310 | defined_params.namespaced_params.each do |prms| 311 | doc.namespace(prms.space_name.name) do |ns| 312 | prms.list_optional.each do |rule| 313 | ns.param(rule.name, rule.options[:doc]) if rule.options[:doc] 314 | end 315 | prms.list_required.each do |rule| 316 | ns.param(rule.name, rule.options[:doc]) if rule.options[:doc] 317 | end 318 | end 319 | end 320 | 321 | defined_params.list_optional.each do |rule| 322 | doc.param(rule.name, rule.options[:doc]) if rule.options[:doc] 323 | end 324 | 325 | defined_params.list_required.each do |rule| 326 | doc.param(rule.name, rule.options[:doc]) if rule.options[:doc] 327 | end 328 | end 329 | 330 | # Left for generators to implement. It's empty because WD itself isn't concerned 331 | # with implementation, but needs it defined so doc generation can read WD web 332 | # service definitions. 333 | def implementation(&block) 334 | end 335 | 336 | SERVICE_ROOT_REGEXP = /(.*?)[\/\(\.]/ 337 | SERVICE_ACTION_REGEXP = /[\/\(\.]([a-z0-9_]+)[\/\(\.\?]/i 338 | SERVICE_RESTFUL_SHOW_REGEXP = /\/:[a-z0-9_]+\.\w{3}$/ 339 | 340 | private 341 | 342 | # extracts the service root name out of the url using a regexp 343 | def extract_service_root_name(url) 344 | url[SERVICE_ROOT_REGEXP, 1] || url 345 | end 346 | 347 | # extracts the action name out of the url using a regexp 348 | # Defaults to the list action 349 | def extract_service_action(url) 350 | if url =~ SERVICE_RESTFUL_SHOW_REGEXP 351 | 'show' 352 | else 353 | url[SERVICE_ACTION_REGEXP, 1] || 'list' 354 | end 355 | end 356 | 357 | # Check if we need to use a restful route in which case we need 358 | # to update the service action 359 | def update_restful_action(verb) 360 | if verb != :get && @action && @action == 'list' 361 | case verb 362 | when :post 363 | @action = 'create' 364 | when :put 365 | @action = 'update' 366 | when :delete 367 | @action = 'destroy' 368 | end 369 | end 370 | end 371 | 372 | end 373 | -------------------------------------------------------------------------------- /lib/weasel_diesel/cli.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require_relative "../weasel_diesel" 3 | 4 | class WeaselDiesel 5 | class CLI < Thor 6 | include Thor::Actions 7 | namespace :weasel_diesel 8 | 9 | desc "generate_doc SOURCE_PATH DESTINATION_PATH", "Generate HTML documentation for WeaselDiesel web services" 10 | def generate_doc(source_path, destination_path="doc") 11 | api_files = Dir.glob(File.join(destination_root, source_path, "**", "*.rb")) 12 | if api_files.empty? 13 | puts "No ruby files in source_path: #{File.join(destination_root, source_path)}" 14 | return 15 | end 16 | api_files.each do |api| 17 | require api 18 | end 19 | 20 | require 'fileutils' 21 | destination = File.join(destination_root, destination_path) 22 | FileUtils.mkdir_p(destination) unless File.exist?(destination) 23 | File.open("#{destination}/index.html", "w"){|f| f << doc_template.result(binding)} 24 | puts "Documentation available there: #{destination}/index.html" 25 | `open #{destination}/index.html` if RUBY_PLATFORM =~ /darwin/ && !ENV['DONT_OPEN'] 26 | end 27 | 28 | private 29 | 30 | def response_element_html(el) 31 | response_element_template.result(binding) 32 | end 33 | 34 | def input_params_html(required, optional) 35 | input_params_template.result(binding) 36 | end 37 | 38 | def input_params_template 39 | file = resources.join '_input_params.erb' 40 | ERB.new File.read(file) 41 | end 42 | 43 | def response_element_template 44 | file = resources.join '_response_element.erb' 45 | ERB.new File.read(file) 46 | end 47 | 48 | def doc_template 49 | file = resources.join 'template.erb' 50 | ERB.new File.read(file) 51 | end 52 | 53 | def resources 54 | require 'pathname' 55 | @resources ||= Pathname.new(File.join(File.dirname(__FILE__), 'doc_generator')) 56 | end 57 | 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/weasel_diesel/doc_generator/_input_params.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% [["required", required], ["optional", optional]].each do |label, rules| %> 4 | <% unless rules.empty? %> 5 | <% rules.each do |rule| %> 6 | 7 | 12 | 31 | 32 | <% end %> 33 | <% end %> 34 | <% end %> 35 | 36 |
    8 | <%= rule.name %> 9 |
    10 | <%= label %> 11 |
    13 |

    14 | <%= rule.options[:type] || 'string' %> 15 | 16 | <% if desc = rule.doc %> 17 | - <%= desc %> 18 | <% end %> 19 |

    20 | 21 | <% if options = rule.options[:options] %> 22 | Options: <%= options.join(', ') %> 23 |
    24 | <% end %> 25 | 26 | <% if default = rule.options[:default] %> 27 | Default: <%= default %> 28 |
    29 | <% end %> 30 |
    37 | -------------------------------------------------------------------------------- /lib/weasel_diesel/doc_generator/_response_element.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% if el.name %> 4 | 5 | 11 | 12 | <% end %> 13 | <% el.properties.each do |prop| %> 14 | 15 | 26 | 34 | 35 | <% end %> 36 | 37 | <% if false && el.arrays %> 38 | <% el.arrays.each do |e| %> 39 | <%= response_element_html(e) %> 40 | <% end %> 41 | <% end %> 42 | 43 | <% if false && el.elements %> 44 | <% el.elements.each do |e| %> 45 | <%= response_element_html(e) %> 46 | <% end %> 47 | <% end %> 48 | 49 |
    6 | <%= el.name %> 7 | <% if false && el.is_a?(WeaselDiesel::Response::Vector) %> 8 | (Array, these are the properties of each array item) 9 | <% end %> 10 |
    16 | <%= prop.name %> 17 |
    18 | 19 | <% if false && prop.opts && prop.opts.respond_to?(:[]) && prop.opts[:null] %> 20 | required 21 | <% else %> 22 | optional 23 | <% end %> 24 | 25 |
    27 |

    28 | <%= prop.type %> 29 | <% unless true && prop.doc.nil? or prop.doc.empty? %> 30 | - <%= prop.doc %> 31 | <% end %> 32 |

    33 |
    50 | -------------------------------------------------------------------------------- /lib/weasel_diesel/doc_generator/template.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | API Documentation 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 |
    19 | 29 | 30 |
    31 | 39 |
    40 |
    41 |

    API Documentation

    42 |

    Resources are listed on the sidebar to the left.

    43 |
    44 | 45 | <% WSList.all.each do |api| %> 46 |
    "> 47 |
    48 |

    <%= api.verb.upcase %> <%= '[SSL]' if api.ssl %> <%= api.url %>

    49 | <% if api.auth_required %> 50 | Authentication required 51 | <% end %> 52 | 53 | <% if api.doc.desc %> 54 |

    55 | <%= "#{api.doc.desc}" %> 56 |

    57 | <% end %> 58 |
    59 | 60 |
    61 |

    Parameters

    62 | <% if api.required_rules.any? || api.optional_rules.any? %> 63 | <%= input_params_html(api.required_rules, api.optional_rules) %> 64 | <% end %> 65 | 66 |
      67 | <% api.params.namespaced_params.each do |params| %> 68 |
    • 69 | <%= params.space_name.name %> 70 | 71 | (<%= params.space_name.null ? 'optional' : 'required' %>) 72 | 73 | <%= input_params_html(params.list_required, params.list_optional) %> 74 |
    • 75 | <% end %> 76 |
    77 |
    78 | 79 |
    80 |

    Example Request

    81 | <% api.doc.examples.each do |example| %> 82 |
    <%= example %>
    83 | <% end %> 84 |
    85 | 86 |
    87 | <% if api.response.nodes.any? %> 88 |

    Response

    89 | <% if api.response.arrays %> 90 | (Array, these are the properties of each array item) 91 | <% end %> 92 | 93 | <% api.response.arrays.each do |array| %> 94 | <%= response_element_html(array) %> 95 | <% end %> 96 | <% api.response.elements.each do |el| %> 97 | <%= response_element_html(el) %> 98 | <% end %> 99 | <% end %> 100 |

    Example

    101 |
    <%= JSON.pretty_generate(JSON.parse(api.response.to_json)) %>
    102 |
    103 | 104 |
    105 |
    106 | <% end %> 107 | 108 |
    109 |

    © <%= Time.now.year %>

    110 |
    111 |
    112 |
    113 |
    114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /lib/weasel_diesel/dsl.rb: -------------------------------------------------------------------------------- 1 | # Extending the top level module to add some helpers 2 | # 3 | # @api public 4 | class WeaselDiesel 5 | module DSL 6 | private 7 | 8 | # Base DSL method called to describe a service 9 | # 10 | # @param [String] url The url of the service to add. 11 | # @yield [WeaselDiesel] The newly created service. 12 | # @return [Array] The services already defined 13 | # @example Describing a basic service 14 | # describe_service "hello-world.xml" do |service| 15 | # # describe the service 16 | # end 17 | # 18 | # @api public 19 | def describe_service(url, &block) 20 | service = WeaselDiesel.new(url) 21 | yield service 22 | 23 | service.sync_input_param_doc 24 | WSList.add(service) 25 | 26 | service 27 | end 28 | 29 | end 30 | end 31 | 32 | # Extend the main object with the DSL methods. This allows top-level calls 33 | # without polluting the object inheritance tree. 34 | self.extend WeaselDiesel::DSL -------------------------------------------------------------------------------- /lib/weasel_diesel/version.rb: -------------------------------------------------------------------------------- 1 | class WeaselDiesel 2 | VERSION = "1.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/ws_list.rb: -------------------------------------------------------------------------------- 1 | # Wrapper module to keep track of all defined services 2 | # 3 | # @api public 4 | module WSList 5 | 6 | class UnknownService < StandardError; end 7 | class DuplicateServiceDescription < StandardError; end 8 | 9 | module_function 10 | 11 | # Add a service to the array tracking 12 | # the playco services 13 | # 14 | # @param [WeaselDiesel] The service to add. 15 | # @return [Array] All the added services. 16 | # @raise DuplicateServiceDescription If a service is being duplicated. 17 | # @api public 18 | def add(service) 19 | @list ||= [] 20 | if WSList.find(service.verb, service.url) 21 | raise DuplicateServiceDescription, "A service accessible via #{service.verb} #{service.url} already exists" 22 | end 23 | @list << service 24 | @list 25 | end 26 | 27 | # Returns an array of services 28 | # 29 | # @return [Array] All the added services. 30 | # @api public 31 | def all 32 | @list || [] 33 | end 34 | 35 | # Returns a service based on its name 36 | # 37 | # @param [String] name The name of the service you are looking for. 38 | # @raise [UnknownService] if a service with the passed name isn't found. 39 | # @return [WeaselDiesel] The found service. 40 | # 41 | # @api public 42 | # @deprecated 43 | def named(name) 44 | service = all.find{|service| service.name == name} 45 | if service.nil? 46 | raise UnknownService, "Service named #{name} isn't available" 47 | else 48 | service 49 | end 50 | end 51 | 52 | # Returns a service based on its url 53 | # 54 | # @param [String] url The url of the service you are looking for. 55 | # @return [Nil, WeaselDiesel] The found service. 56 | # 57 | # @api public 58 | # @deprecated use #find instead since this method doesn't support a verb being passed 59 | # and the url might or might not match depending on the leading slash. 60 | def [](url) 61 | @list.find{|service| service.url == url} 62 | end 63 | 64 | # Returns a service based on its verb and url 65 | # 66 | # @param [String] verb The request method (GET, POST, PUT, DELETE) 67 | # @param [String] url The url of the service you are looking for. 68 | # @return [Nil, WeaselDiesel] The found service. 69 | # 70 | # @api public 71 | def find(verb, url) 72 | verb = verb.to_s.downcase.to_sym 73 | slashed_url = url.start_with?('/') ? url : "/#{url}" 74 | @list.find{|service| service.verb == verb && service.url == slashed_url} 75 | end 76 | 77 | 78 | end 79 | 80 | -------------------------------------------------------------------------------- /spec/hello_world_service.rb: -------------------------------------------------------------------------------- 1 | describe_service "hello_world.xml" do |service| 2 | service.formats :xml 3 | service.http_verb :get 4 | service.disable_auth # on by default 5 | 6 | service.param.string :name, :default => 'World' 7 | 8 | service.response do |response| 9 | response.element(:name => "greeting") do |e| 10 | e.attribute "message" => :string, :doc => "The greeting message sent back." 11 | end 12 | end 13 | 14 | service.documentation do |doc| 15 | doc.overall "This service provides a simple hello world implementation example." 16 | doc.params :name, "The name of the person to greet." 17 | doc.example "http://ps3.yourgame.com/hello_world.xml?name=Matt" 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /spec/json_response_description_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe "WeaselDiesel JSON response description" do 4 | 5 | # JSON response example 6 | =begin 7 | { vouchers: [ 8 | { 9 | id : 1, 10 | redeemed : false, 11 | created_at : 123123123123, 12 | option: { 13 | id : 1231, 14 | price: 123.32 15 | } 16 | }, 17 | { 18 | id : 2, 19 | redeemed : true, 20 | created_at : 123123123123, 21 | option: { 22 | id : 1233, 23 | price: 1.32 24 | } 25 | }, 26 | ] } 27 | =end 28 | 29 | before :all do 30 | @timestamp = Time.now.to_i 31 | @service = describe_service "json_list" do |service| 32 | service.formats :json 33 | service.response do |response| 34 | response.array :vouchers do |node| 35 | node.key :id 36 | node.type :Voucher 37 | node.string :name, :mock => "test" 38 | node.integer :id, :doc => "Identifier" 39 | node.boolean :redeemed 40 | node.datetime :created_at, :mock => @timestamp 41 | node.object :option do |option| 42 | option.integer :id 43 | option.integer :deal_id, :mock => 1 44 | option.float :price 45 | end 46 | end 47 | end 48 | end 49 | @response = @service.response 50 | @root_node = @response.nodes.find{|n| n.name == :vouchers} 51 | 52 | end 53 | 54 | it "should handle the json root node" do 55 | @root_node.should_not be_nil 56 | end 57 | 58 | it "should handle a node property list" do 59 | props = @root_node.properties 60 | props.should_not be_empty 61 | {:id => :integer, :redeemed => :boolean, :created_at => :datetime}.each do |key, type| 62 | prop = props.find{|prop| prop.name == key} 63 | prop.should_not be_nil 64 | prop.type.should == type 65 | end 66 | end 67 | 68 | it "should handle a nested object with properties" do 69 | @root_node.objects.should_not be_nil 70 | option = @root_node.objects.find{|o| o.name == :option} 71 | option.should_not be_nil 72 | {:id => :integer, :deal_id => :integer, :price => :float}.each do |key, type| 73 | prop = option.properties.find{|prop| prop.name == key} 74 | if prop.nil? 75 | puts option.properties.inspect 76 | puts [key, type].inspect 77 | end 78 | prop.should_not be_nil 79 | prop.type.should == type 80 | end 81 | end 82 | 83 | it "should allow some meta attributes" do 84 | atts = @root_node.meta_attributes 85 | atts.should_not be_nil 86 | {:key => :id, :type => :Voucher}.each do |type, value| 87 | meta = atts.find{|att| att.type == type} 88 | puts [type, atts].inspect if meta.nil? 89 | meta.should_not be_nil 90 | meta.value.should == value 91 | end 92 | @root_node.key.should == :id 93 | @root_node.type.should == :Voucher 94 | end 95 | 96 | it "should handle mocked values properly" do 97 | created_at = @root_node.properties.find{|prop| prop.name == :created_at} 98 | created_at.opts[:mock].should == @timestamp 99 | option = @root_node.objects.find{|prop| prop.name == :option} 100 | deal_id = option.properties.find{|prop| prop.name == :deal_id} 101 | deal_id.opts[:mock].should == 1 102 | name = @root_node.properties.find{|prop| prop.name == :name} 103 | name.opts[:mock].should == "test" 104 | end 105 | 106 | it "should allow an anonymous object at the root of the response" do 107 | service = describe_service "json_anonymous_obj" do |service| 108 | service.formats :json 109 | service.response do |response| 110 | response.object do |obj| 111 | obj.integer :id 112 | obj.string :foo 113 | end 114 | end 115 | end 116 | response = service.response 117 | response.nodes.should_not be_empty 118 | obj = response.nodes.first 119 | obj.should_not be_nil 120 | obj.properties.find{|prop| prop.name == :id}.should_not be_nil 121 | obj.properties.find{|prop| prop.name == :foo}.should_not be_nil 122 | end 123 | 124 | end 125 | 126 | 127 | 128 | describe "WeaselDiesel simple JSON object response description" do 129 | 130 | # JSON response example 131 | =begin 132 | {"organization": {"name": "Example"}} 133 | =end 134 | 135 | before :all do 136 | @timestamp = Time.now.to_i 137 | @service = describe_service "json_obj" do |service| 138 | service.formats :json 139 | service.response do |response| 140 | response.object :organization do |node| 141 | node.string :name 142 | end 143 | end 144 | end 145 | @response = @service.response 146 | end 147 | 148 | it "should have a properly structured reponse" do 149 | top_object = @service.response.element_named(:organization) 150 | top_object.should_not be_nil 151 | name_node = top_object.properties.find{|o| o.name == :name} 152 | name_node.should_not be_nil 153 | name_node.type.should == :string 154 | end 155 | 156 | end 157 | 158 | 159 | describe "WeaselDiesel anonymous JSON object response description" do 160 | 161 | # JSON response example 162 | =begin 163 | {"name": "Example"} 164 | =end 165 | 166 | before :all do 167 | @timestamp = Time.now.to_i 168 | @service = describe_service "anon_json_obj" do |service| 169 | service.formats :json 170 | service.response do |response| 171 | response.object do |node| 172 | node.string :name 173 | end 174 | end 175 | end 176 | @response = @service.response 177 | end 178 | 179 | it "should have a properly structured response" do 180 | top_object = @service.response.elements.first 181 | top_object.should_not be_nil 182 | name_node = top_object.properties.find{|o| o.name == :name} 183 | name_node.should_not be_nil 184 | name_node.type.should == :string 185 | end 186 | 187 | end 188 | 189 | 190 | describe "WeaselDiesel top level array response description" do 191 | 192 | =begin 193 | '[ { "name":"Bob" }, { "name": "Judy" } ]' 194 | =end 195 | 196 | before :all do 197 | @service = describe_service 'tl_array' do |service| 198 | service.formats :json 199 | service.response do |response| 200 | # anonymous array response 201 | response.array do |arr| 202 | arr.object do |node| 203 | node.string :name 204 | end 205 | end 206 | end 207 | end 208 | end 209 | 210 | it "should have a properly structured response" do 211 | top_object = @service.response.nodes.first 212 | top_object.should_not be_nil 213 | top_object.should be_an_instance_of(WeaselDiesel::Response::Vector) 214 | top_object.elements.first.should_not be_nil 215 | top_object.elements.first.attributes.first.name.should eq(:name) 216 | top_object.elements.first.attributes.first.type.should eq(:string) 217 | end 218 | 219 | end 220 | -------------------------------------------------------------------------------- /spec/json_response_verification_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | require_relative '../lib/json_response_verification' 3 | 4 | WeaselDiesel.send(:include, JSONResponseVerification) 5 | 6 | describe "JSON response verification" do 7 | 8 | before :all do 9 | @service = describe_service "json_response_verification" do |service| 10 | service.response do |response| 11 | response.element(:name => :user) do |user| 12 | user.integer :id 13 | user.string :name 14 | user.datetime :created_at 15 | user.object :creds do |creds| 16 | creds.integer :id 17 | creds.float :price 18 | creds.boolean :enabled 19 | end 20 | end 21 | end 22 | end 23 | 24 | @second_service = describe_service "anonym_obj_json_response_verification" do |service| 25 | service.response do |response| 26 | response.object do |user| 27 | user.integer :id 28 | user.string :name, :null => true 29 | user.datetime :created_at 30 | user.object :creds do |creds| 31 | creds.integer :id 32 | creds.float :price 33 | creds.boolean :enabled 34 | end 35 | end 36 | end 37 | end 38 | 39 | @third_service = describe_service "with_array" do |service| 40 | service.response do |response| 41 | response.array :users do |node| 42 | node.integer :id 43 | node.string :name 44 | node.boolean :admin, :doc => "true if the user is an admin" 45 | node.string :state, :doc => "test", :null => true 46 | node.datetime :last_login_at 47 | end 48 | end 49 | end 50 | 51 | @forth_service = describe_service "with_nested_array" do |service| 52 | service.response do |response| 53 | response.array :users do |node| 54 | node.integer :id 55 | node.string :name 56 | node.boolean :admin, :doc => "true if the user is an admin" 57 | node.string :state, :doc => "test" 58 | node.array :pets do |pet| 59 | pet.integer :id 60 | pet.string :name 61 | end 62 | end 63 | end 64 | end 65 | 66 | @optional_prop_service = describe_service "opt_prop_service" do |service| 67 | service.response do |response| 68 | response.object do |obj| 69 | obj.string :email, :null => true 70 | obj.integer :city_id, :null => true 71 | obj.string :full_name, :null => true 72 | end 73 | end 74 | end 75 | 76 | @top_level_array_service = describe_service "tl_array#{__LINE__}" do |service| 77 | service.formats :json 78 | service.response do |response| 79 | # anonymous array response 80 | response.array do |arr| 81 | arr.object do |node| 82 | node.string :name 83 | end 84 | end 85 | end 86 | end 87 | 88 | end 89 | 90 | 91 | def valid_response(namespaced=true) 92 | response = { 93 | "id" => 1, 94 | "name" => "matt", 95 | "created_at" => "2011-09-22T16:32:46-07:00", 96 | "creds" => { "id" => 42, "price" => 2010.07, "enabled" => false } 97 | } 98 | namespaced ? {"user" => response} : response 99 | end 100 | 101 | def valid_array_response 102 | {"users" => [ 103 | {"id" => 1, 104 | "admin" => true, 105 | "name" => "Heidi", 106 | "state" => "retired", 107 | "last_login_at" => "2011-09-22T22:46:35-07:00" 108 | }, 109 | {"id" => 2, 110 | "admin" => false, 111 | "name" => "Giana", 112 | "state" => "playing", 113 | "last_login_at" => "2011-09-22T22:46:35-07:00" 114 | }] 115 | } 116 | end 117 | 118 | def valid_nested_array_response 119 | {"users" => [ 120 | {"id" => 1, 121 | "admin" => true, 122 | "name" => "Heidi", 123 | "state" => "retired", 124 | "pets" => [] 125 | }, 126 | {"id" => 2, 127 | "admin" => false, 128 | "name" => "Giana", 129 | "state" => "playing", 130 | "pets" => [{"id" => 23, "name" => "medor"}, {"id" => 34, "name" => "rex"}] 131 | }] 132 | } 133 | end 134 | 135 | def valid_top_level_array_response 136 | [ { "name" => "Bob" }, { "name" => "Judy" } ] 137 | end 138 | 139 | it "should validate the response" do 140 | valid, errors = @service.verify(valid_response) 141 | errors.should == [] 142 | valid.should be_true 143 | errors.should be_empty 144 | end 145 | 146 | it "should detect that the response is missing the top level object" do 147 | response = valid_response 148 | response.delete("user") 149 | valid, errors = @service.verify(response) 150 | valid.should be_false 151 | errors.should_not be_empty 152 | end 153 | 154 | it "should detect that a property integer type is wrong" do 155 | response = valid_response 156 | response["user"]["id"] = 'test' 157 | valid, errors = @service.verify(response) 158 | valid.should be_false 159 | errors.should_not be_empty 160 | errors.first.should match(/id/) 161 | errors.first.should match(/wrong type/) 162 | end 163 | 164 | it "should detect that an integer attribute value is nil" do 165 | response = valid_response 166 | response["user"]["id"] = nil 167 | valid, errors = @service.verify(response) 168 | valid.should be_false 169 | errors.should_not be_empty 170 | errors.first.should match(/id/) 171 | errors.first.should match(/wrong type/) 172 | end 173 | 174 | it "should detect that a string attribute value is nil [bug]" do 175 | response = valid_response 176 | response["user"]["name"] = nil 177 | valid, errors = @service.verify(response) 178 | valid.should be_false 179 | errors.should_not be_empty 180 | errors.first.should match(/name/) 181 | errors.first.should match(/wrong type/) 182 | end 183 | 184 | it "should detect that a nested object is missing" do 185 | response = valid_response 186 | response["user"].delete("creds") 187 | valid, errors = @service.verify(response) 188 | valid.should be_false 189 | errors.first.should match(/creds/) 190 | errors.first.should match(/missing/) 191 | end 192 | 193 | it "should validate non namespaced responses" do 194 | valid, errors = @second_service.verify(valid_response(false)) 195 | valid.should be_true 196 | end 197 | 198 | it "should validate nil attributes if marked as nullable" do 199 | response = valid_response(false) 200 | response["name"] = nil 201 | valid, errors = @second_service.verify(response) 202 | valid.should be_true 203 | end 204 | 205 | it "should validate array items" do 206 | valid, errors = @third_service.verify(valid_array_response) 207 | valid.should be_true 208 | errors.should be_empty 209 | end 210 | 211 | it "should validate an empty array" do 212 | response = valid_array_response 213 | response["users"] = [] 214 | valid, errors = @third_service.verify(response) 215 | valid.should be_true 216 | end 217 | 218 | it "should catch error in an array item" do 219 | response = valid_array_response 220 | response["users"][1]["id"] = 'test' 221 | valid, errors = @third_service.verify(response) 222 | valid.should be_false 223 | errors.should_not be_empty 224 | end 225 | 226 | it "should validate nested arrays" do 227 | valid, errors = @forth_service.verify(valid_nested_array_response) 228 | valid.should be_true 229 | end 230 | 231 | it "should respect optional properties" do 232 | valid, errors = @optional_prop_service.verify({}) 233 | valid.should be_true 234 | end 235 | 236 | it "should validate the response" do 237 | valid, errors = @service.verify(valid_response) 238 | errors.should == [] 239 | valid.should be_true 240 | errors.should be_empty 241 | end 242 | 243 | it "should validated a top level array" do 244 | valid, errors = @top_level_array_service.verify(valid_top_level_array_response) 245 | errors.should == [] 246 | valid.should be_true 247 | errors.should be_empty 248 | end 249 | 250 | end 251 | -------------------------------------------------------------------------------- /spec/params_verification_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "spec_helper" 2 | require_relative '../lib/params_verification' 3 | 4 | describe ParamsVerification do 5 | 6 | before :all do 7 | @service = WSList.find(:get, '/services/test.xml') 8 | @service.should_not be_nil 9 | @valid_params = {'framework' => 'RSpec', 'version' => '1.02', 'options' => nil, 'user' => {'id' => '123', 'groups' => 'manager,developer', 'skills' => 'java,ruby'}} 10 | end 11 | 12 | def copy(params) 13 | Marshal.load( Marshal.dump(params) ) 14 | end 15 | 16 | it "should validate valid params" do 17 | params = copy(@valid_params) 18 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should_not raise_exception 19 | params['name'] = 'Mattetti' 20 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should_not raise_exception 21 | end 22 | 23 | it "should return the params" do 24 | params = copy(@valid_params) 25 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 26 | returned_params.should be_an_instance_of(Hash) 27 | returned_params.keys.size.should >= 3 28 | end 29 | 30 | it "shouldn't set empty nil values for optional params that aren't passed" do 31 | params = copy(@valid_params) 32 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 33 | returned_params.has_key?('name').should be_false 34 | end 35 | 36 | it "should return array in the params" do 37 | params = copy(@valid_params) 38 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 39 | returned_params['user']['groups'].should be == @valid_params['user']['groups'].split(",") 40 | returned_params['user']['skills'].should be == @valid_params['user']['skills'].split(",") 41 | end 42 | 43 | it "should not duplicate params in the root level" do 44 | params = copy(@valid_params) 45 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 46 | returned_params['groups'].should be_nil 47 | returned_params['skills'].should be_nil 48 | end 49 | 50 | it "should raise an exception when values of required param are not in the allowed list" do 51 | params = copy(@valid_params) 52 | params['user']['groups'] = 'admin,root,manager' 53 | lambda { ParamsVerification.validate!(params, @service.defined_params) }.should raise_error(ParamsVerification::InvalidParamValue) 54 | end 55 | 56 | it "should raise an exception when values of optional param are not in the allowed list" do 57 | params = copy(@valid_params) 58 | params['user']['skills'] = 'ruby,java,php' 59 | lambda { ParamsVerification.validate!(params, @service.defined_params) }.should raise_error(ParamsVerification::InvalidParamValue) 60 | end 61 | 62 | it "should set the default value for an optional param" do 63 | params = copy(@valid_params) 64 | params['timestamp'].should be_nil 65 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 66 | returned_params['timestamp'].should_not be_nil 67 | end 68 | 69 | it "should support various datetime formats" do 70 | params = copy(@valid_params) 71 | params['timestamp'] = Time.now.iso8601 72 | lambda { ParamsVerification.validate!(params, @service.defined_params) }.should_not raise_error 73 | params['timestamp'] = Time.now.getutc.iso8601 74 | lambda { ParamsVerification.validate!(params, @service.defined_params) }.should_not raise_error 75 | end 76 | 77 | it "should set the default value for a namespace optional param" do 78 | params = copy(@valid_params) 79 | params['user']['mailing_list'].should be_nil 80 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 81 | returned_params['user']['mailing_list'].should be_true 82 | end 83 | 84 | it "should verify child param rules if namespace is not null, but it nullable" do 85 | params = copy(@valid_params) 86 | params['options'] = {'verbose' => 'true'} 87 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 88 | returned_params['options']['verbose'].should be_true 89 | end 90 | 91 | it "should skip child param rules if namespace is null" do 92 | params = copy(@valid_params) 93 | params['options'].should be_nil 94 | returned_params = ParamsVerification.validate!(params, @service.defined_params) 95 | returned_params['options'].should be_nil 96 | end 97 | 98 | it "should raise an exception when a required param is missing" do 99 | params = copy(@valid_params) 100 | params.delete('framework') 101 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::MissingParam) 102 | end 103 | 104 | it "should cast a comma delimited string into an array when param marked as an array" do 105 | service = WSList.find(:post, "/services/array_param.xml") 106 | service.should_not be_nil 107 | params = {'seq' => "a,b,c,d,e,g"} 108 | validated = ParamsVerification.validate!(params, service.defined_params) 109 | validated['seq'].should == %W{a b c d e g} 110 | end 111 | 112 | it "should not raise an exception if a req array param doesn't contain a comma" do 113 | service = WSList.find(:post, "/services/array_param.xml") 114 | params = {'seq' => "a b c d e g"} 115 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should_not raise_exception 116 | end 117 | 118 | it "should raise an exception when a param is of the wrong type" do 119 | params = copy(@valid_params) 120 | params['user']['id'] = 'abc' 121 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamType) 122 | end 123 | 124 | it "should raise an exception when a param is under the min_value" do 125 | params = copy(@valid_params) 126 | params['num'] = '1' 127 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 128 | params['num'] = 1 129 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 130 | end 131 | 132 | it "should raise an exception when a param is over the max_value" do 133 | params = copy(@valid_params) 134 | params['num'] = 10_000 135 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 136 | end 137 | 138 | it "should raise an exception when a param is under the min_length" do 139 | params = copy(@valid_params) 140 | params['name'] ='bob' 141 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 142 | end 143 | 144 | it "should raise an exception when a param is over the max_length" do 145 | params = copy(@valid_params) 146 | params['name'] = "Whether 'tis nobler in the mind to suffer The slings and arrows of outrageous fortune" 147 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 148 | end 149 | 150 | it "should raise an exception when a param isn't in the param option list" do 151 | params = copy(@valid_params) 152 | params['alpha'] = 'z' 153 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 154 | end 155 | 156 | it "should raise an exception when a nested optional param isn't in the param option list" do 157 | params = copy(@valid_params) 158 | params['user']['sex'] = 'large' 159 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 160 | # other service 161 | params = {'preference' => {'region_code' => 'us', 'language_code' => 'de'}} 162 | service = WSList.find(:get, '/preferences.xml') 163 | service.should_not be_nil 164 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 165 | end 166 | 167 | it "should raise an exception when a required param is present but doesn't match the limited set of options" do 168 | service = describe_service "search" do |service| 169 | service.params { |p| p.string :by, :options => ['name', 'code', 'last_four'], :required => true } 170 | end 171 | params = {'by' => 'foo'} 172 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 173 | end 174 | 175 | it "should validate that no params are passed when accept_no_params! is set on a service" do 176 | service = WSList.find(:get, "/services/test_no_params.xml") 177 | service.should_not be_nil 178 | params = copy(@valid_params) 179 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should raise_exception 180 | end 181 | 182 | it "should raise an exception when an unexpected param is found" do 183 | params = copy(@valid_params) 184 | params['attack'] = true 185 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::UnexpectedParam) 186 | end 187 | 188 | it "should prevent XSS attack on unexpected param name being listed in the exception message" do 189 | params = copy(@valid_params) 190 | params["7e22ce88ff3f0952"] = 1 191 | escaped_error_message = /7e22c<script>alert\(.*\)<\/script>e88ff3f0952/ 192 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::UnexpectedParam, escaped_error_message) 193 | end 194 | 195 | it "should make sure that optional params marked as not false are being set" do 196 | params = copy(@valid_params) 197 | ParamsVerification.validate!(params, @service.defined_params).should be_true 198 | params.delete('version') 199 | # if omitted, the param should not raise an exception 200 | ParamsVerification.validate!(params, @service.defined_params).should be_true 201 | params['version'] = '' 202 | lambda{ ParamsVerification.validate!(params, @service.defined_params) }.should raise_exception(ParamsVerification::InvalidParamValue) 203 | end 204 | 205 | it "should allow optional null integer params" do 206 | service = WeaselDiesel.new("spec") 207 | service.params do |p| 208 | p.integer :id, :optional => true, :null => true 209 | end 210 | params = {"id" => ""} 211 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should_not raise_exception 212 | params = {"id" => nil} 213 | lambda{ ParamsVerification.validate!(params, service.defined_params) }.should_not raise_exception 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /spec/preferences_service.rb: -------------------------------------------------------------------------------- 1 | describe_service "preferences.xml" do |service| 2 | 3 | service.params do |p| 4 | p.namespace :preference do |pr| 5 | pr.string :language_code, :options => ['en', 'fr'] 6 | pr.string :region_code, :options => ['europe'] 7 | end 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'rack/test' 3 | 4 | require_relative "../lib/weasel_diesel" 5 | require_relative 'test_services' 6 | require_relative 'preferences_service' 7 | 8 | ENV["RACK_ENV"] = 'test' 9 | 10 | RSpec.configure do |conf| 11 | conf.include WeaselDiesel::DSL 12 | conf.include Rack::Test::Methods 13 | end 14 | -------------------------------------------------------------------------------- /spec/test_services.rb: -------------------------------------------------------------------------------- 1 | unless Object.const_defined?("WeaselDieselSpecOptions") 2 | WeaselDieselSpecOptions = ['RSpec', 'Bacon'] # usually pulled from a model 3 | end 4 | 5 | describe_service "services/test.xml" do |service| 6 | service.formats :xml, :json 7 | service.http_verb :get 8 | 9 | service.params do |p| 10 | p.string :framework, 11 | :in => WeaselDieselSpecOptions, 12 | :required => true, 13 | :doc => "The test framework used, could be one of the two following: #{WeaselDieselSpecOptions.join(", ")}." 14 | 15 | p.datetime :timestamp, :default => Time.now.iso8601 16 | p.string :alpha, :in => ['a', 'b', 'c'] 17 | p.string :version, :null => false, :doc => "The version of the framework to use." 18 | p.integer :num, :min_value => 42, :max_value => 1000, :doc => "The number to test" 19 | p.string :name, :min_length => 5, :max_length => 25 20 | end 21 | 22 | # service.param :delta, :optional => true, :type => 'float' 23 | # # if the optional flag isn't passed, the param is considered required. 24 | # service.param :epsilon, :type => 'string' 25 | 26 | service.params.namespace :user do |user| 27 | user.integer :id, :required => :true 28 | user.string :sex, :in => %Q{female, male} 29 | user.boolean :mailing_list, :default => true, :doc => "is the user subscribed to the ML?" 30 | user.array :groups, :required => true, :in => %w{developer admin manager} 31 | user.array :skills, :in => %w{ruby java networking} 32 | end 33 | 34 | service.params.namespace :options, :null => true do |option| 35 | option.boolean :verbose, :default => false, :required => :true 36 | end 37 | 38 | # the response contains a list of player creation ratings each object in the list 39 | 40 | =begin 41 | service.response do |response| 42 | response.element(:name => "player_creation_ratings") do |e| 43 | e.attribute :id => :integer, :doc => "id doc" 44 | e.attribute :is_accepted => :boolean, :doc => "is accepted doc" 45 | e.attribute :name => :string, :doc => "name doc" 46 | 47 | e.array :player_creation_rating, 'PlayerCreationRating' do |a| 48 | a.attribute :comments => :string, :doc => "comments doc" 49 | a.attribute :player_id => :integer, :doc => "player_id doc" 50 | a.attribute :rating => :integer, :doc => "rating doc" 51 | a.attribute :username => :string, :doc => "username doc" 52 | end 53 | end 54 | end 55 | =end 56 | 57 | service.response do |response| 58 | response.element(:name => "player_creation_ratings") do |e| 59 | e.integer :id, :doc => "id doc" 60 | e.boolean :is_accepted, :doc => "is accepted doc" 61 | e.string :name, :doc => "name doc" 62 | 63 | e.array :player_creation_rating, 'PlayerCreationRating' do |a| 64 | a.string :comments, :doc => "comments doc" 65 | a.integer :player_id, :doc => "player_id doc" 66 | a.integer :rating, :doc => "rating doc" 67 | a.string :username, :doc => "username doc" 68 | end 69 | end 70 | end 71 | 72 | 73 | service.documentation do |doc| 74 | # doc.overall 75 | doc.overall <<-DOC 76 | This is a test service used to test the framework. 77 | DOC 78 | 79 | # doc.example 80 | doc.example <<-DOC 81 | The most common way to use this service looks like that: 82 | http://example.com/services/test.xml?framework=rspec&version=2.0.0 83 | DOC 84 | end 85 | end 86 | 87 | describe_service "services/test.xml" do |service| 88 | service.formats :xml, :json 89 | service.http_verb :delete 90 | 91 | service.params do |p| 92 | p.integer :id, :doc => "id of item to be deleted" 93 | end 94 | 95 | service.response do |response| 96 | response.element(:name => "player_creation_ratings") do |e| 97 | e.integer :id, :doc => "id doc" 98 | end 99 | end 100 | 101 | 102 | service.documentation do |doc| 103 | # doc.overall 104 | doc.overall <<-DOC 105 | This deletes a test service. 106 | DOC 107 | end 108 | end 109 | 110 | 111 | 112 | describe_service "services/test_no_params.xml" do |service| 113 | service.formats :xml 114 | service.http_verb :get 115 | service.accept_no_params! 116 | end 117 | 118 | describe_service "services.xml" do |service| 119 | service.formats :xml 120 | service.http_verb :put 121 | 122 | end 123 | 124 | describe_service "services/array_param.xml" do |s| 125 | s.formats :xml 126 | s.http_verb :post 127 | 128 | s.params do |p| 129 | p.array :seq, :required => true 130 | end 131 | 132 | end 133 | 134 | describe_service "/slash/foo" do |service| 135 | service.formats :json 136 | end 137 | 138 | describe_service "/" do |service| 139 | service.extra["name"] = "root" 140 | end 141 | -------------------------------------------------------------------------------- /spec/wd_documentation_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe WeaselDiesel::Documentation do 4 | 5 | before :all do 6 | @service = WSList.find(:get, '/services/test.xml') 7 | @service.should_not be_nil 8 | @doc = @service.doc 9 | @doc.should_not be_nil 10 | end 11 | 12 | it "should have an overall description" do 13 | @doc.desc.strip.should == "This is a test service used to test the framework." 14 | end 15 | 16 | it "should have a list of params doc" do 17 | @doc.params_doc.should be_an_instance_of(Hash) 18 | @doc.params_doc.keys.sort.should == [:framework, :num, :version] 19 | @doc.params_doc[:framework].should == "The test framework used, could be one of the two following: #{WeaselDieselSpecOptions.join(", ")}." 20 | @doc.params_doc[:num].should == 'The number to test' 21 | end 22 | 23 | it "shouldn't list the namespaced documentation at the root of the object" do 24 | @doc.params_doc[:mailing_list].should be_nil 25 | end 26 | 27 | it "should have info on the documented namespaced params" do 28 | @doc.namespaced_params.should_not be_empty 29 | expected_documentation = @doc.namespaced_params.find{|ns| ns.name == :user}.params[:mailing_list] 30 | expected_documentation.should_not be_nil 31 | end 32 | 33 | it "should allow to define namespaced params doc" do 34 | service = WSList.find(:put, "/services.xml") 35 | service.documentation do |doc| 36 | doc.namespace :preference do |ns| 37 | ns.param :id, "Ze id." 38 | end 39 | end 40 | service.doc.namespaced_params.should_not be_empty 41 | ns = service.doc.namespaced_params.find{|ns| ns.name == :preference} 42 | ns.should_not be_nil 43 | ns.params[:id].should == "Ze id." 44 | end 45 | 46 | it "should allow object to be an alias for namespace params" do 47 | service = WSList.find(:put, "/services.xml") 48 | service.documentation do |doc| 49 | doc.object :preference do |ns| 50 | ns.param :id, "Ze id." 51 | end 52 | end 53 | service.doc.namespaced_params.should_not be_empty 54 | ns = service.doc.namespaced_params.find{|ns| ns.name == :preference} 55 | ns.should_not be_nil 56 | ns.params[:id].should == "Ze id." 57 | end 58 | 59 | it "should have an optional list of examples" do 60 | @doc.examples.should be_an_instance_of(Array) 61 | @doc.examples.first.should == <<-DOC 62 | The most common way to use this service looks like that: 63 | http://example.com/services/test.xml?framework=rspec&version=2.0.0 64 | DOC 65 | end 66 | 67 | it "should have the service response documented" do 68 | @doc.response.should_not be_nil 69 | end 70 | 71 | it "should have documentation for the response elements via the response itself" do 72 | @service.response.elements.first.should_not be_nil 73 | @service.response.elements.first.doc.should_not be_nil 74 | @service.response.elements.first.doc.name.should == "player_creation_ratings" 75 | end 76 | 77 | it "should have a json representation of an response element" do 78 | json = @service.response.elements.first.to_json 79 | loaded_json = JSON.load(json) 80 | loaded_json.has_key?(@service.response.elements.first.name).should be_true 81 | loaded_json[@service.response.elements.first.name].should_not be_empty 82 | loaded_json[@service.response.elements.first.name].has_key?("player_creation_rating").should be_true 83 | end 84 | 85 | it "should have documentation for a response element attribute" do 86 | @service.response.elements.first.doc.attributes.should_not be_empty 87 | @service.response.elements.first.doc.attributes[:id].should == "id doc" 88 | end 89 | 90 | it "should have documentation for a response element array" do 91 | element = @service.response.elements.first 92 | element.arrays.should_not be_empty 93 | element.arrays.first.name.should == :player_creation_rating 94 | element.arrays.first.type.should == "PlayerCreationRating" 95 | element.arrays.first.attributes.should_not be_empty 96 | end 97 | 98 | it "should have documentation for the attributes of an response element array" do 99 | element = @service.response.elements.first 100 | array = element.arrays.first 101 | attribute = array.attributes.find{|att| att.name == :comments } 102 | attribute.should_not be_nil 103 | attribute.name.should == :comments # just in case we change the way to find the attribute 104 | attribute.doc.should == "comments doc" 105 | end 106 | 107 | it "should emit html documention for elements" do 108 | @service.response.elements.first.to_html.should be_a(String) 109 | end 110 | 111 | describe "legacy param documentation" do 112 | 113 | before :all do 114 | @original_services = WSList.all.dup 115 | WSList.all.clear 116 | define_service 117 | end 118 | 119 | after :all do 120 | WSList.all.replace @original_services 121 | end 122 | 123 | def define_service 124 | describe_service "legacy_param_doc" do |service| 125 | service.formats :xml, :json 126 | 127 | service.params do |p| 128 | p.string :framework, :in => WeaselDieselSpecOptions, :null => false, :required => true 129 | 130 | p.datetime :timestamp, :default => Time.now 131 | p.string :alpha, :in => ['a', 'b', 'c'] 132 | p.string :version, :null => false 133 | p.integer :num, :minvalue => 42 134 | 135 | end 136 | 137 | service.params.namespace :user do |user| 138 | user.integer :id, :required => :true 139 | user.string :sex, :in => %Q{female, male} 140 | user.boolean :mailing_list, :default => true 141 | end 142 | 143 | service.documentation do |doc| 144 | # doc.overall 145 | doc.overall "This is a test service used to test the framework." 146 | # doc.params , 147 | doc.param :framework, "The test framework used, could be one of the two following: #{WeaselDieselSpecOptions.join(", ")}." 148 | doc.param :version, "The version of the framework to use." 149 | end 150 | end 151 | end 152 | 153 | it "should have the param documented" do 154 | service = WSList.find(:get, "legacy_param_doc") 155 | service.doc.params_doc.keys.sort.should == [:framework, :version] 156 | service.doc.params_doc[service.doc.params_doc.keys.first].should_not be_nil 157 | end 158 | 159 | end 160 | 161 | end 162 | -------------------------------------------------------------------------------- /spec/wd_params_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe WeaselDiesel::Params do 4 | 5 | before :all do 6 | @service = WSList.find(:get, '/services/test.xml') 7 | @service.should_not be_nil 8 | @sparams = @service.params 9 | end 10 | 11 | it "should have the possibility to have a space name" do 12 | @sparams.should respond_to(:space_name) 13 | service_params = WeaselDiesel::Params.new(:space_name => WeaselDiesel::Params::Namespace.new('spec_test')) 14 | service_params.space_name.name.should == 'spec_test' 15 | end 16 | 17 | it "should have a list of required param rules" do 18 | @sparams.list_required.should be_an_instance_of(Array) 19 | @sparams.list_required.length.should == 1 20 | end 21 | 22 | it "should have a list of optional param rules" do 23 | @sparams.list_optional.should be_an_instance_of(Array) 24 | @sparams.list_optional.length.should == 5 25 | end 26 | 27 | it "should have a list of namespaced param rules" do 28 | @sparams.namespaced_params.should be_an_instance_of(Array) 29 | @sparams.namespaced_params.length.should == 2 30 | @sparams.namespaced_params.first.space_name.name.should == :user 31 | @sparams.namespaced_params[1].space_name.name.should == :options 32 | end 33 | 34 | it "should allow to define namespaced param" do 35 | service = WSList.find(:put, "/services.xml") 36 | service.params do |params| 37 | params.namespace :preference do |ns| 38 | ns.param :id, "Ze id." 39 | end 40 | end 41 | service.params.namespaced_params.should_not be_empty 42 | ns = service.params.namespaced_params.find{|ns| ns.space_name.name == :preference} 43 | ns.should_not be_nil 44 | ns.list_optional.first.name.should == "Ze id." 45 | end 46 | 47 | it "should allow object as an alias to namespaced param" do 48 | service = WSList.find(:put, "/services.xml") 49 | service.params do |params| 50 | params.object :preference do |ns| 51 | ns.param :id, "Ze id." 52 | end 53 | end 54 | service.params.namespaced_params.should_not be_empty 55 | ns = service.params.namespaced_params.find{|ns| ns.space_name.name == :preference} 56 | ns.should_not be_nil 57 | ns.list_optional.first.name.should == "Ze id." 58 | end 59 | 60 | describe WeaselDiesel::Params::Rule do 61 | before :all do 62 | @rule = @sparams.list_required.first 63 | @rule.should_not be_nil 64 | end 65 | 66 | it "should have a name" do 67 | @rule.name.should == :framework 68 | end 69 | 70 | it "should have options" do 71 | @rule.options[:type].should == :string 72 | @rule.options[:in].should == WeaselDieselSpecOptions 73 | @rule.options[:null].should be_false 74 | end 75 | end 76 | 77 | describe WeaselDiesel::Params::Namespace do 78 | before :all do 79 | @namespace = @sparams.namespaced_params.first.space_name 80 | @namespace.should_not be_nil 81 | end 82 | 83 | it "should have a name" do 84 | @namespace.name.should == :user 85 | end 86 | 87 | it "should have a null attribute" do 88 | @namespace.null.should be_false 89 | end 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /spec/wd_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe WeaselDiesel do 4 | 5 | before :all do 6 | @service = WSList.find(:get, '/services/test.xml') 7 | @service.should_not be_nil 8 | end 9 | 10 | it "should have an url" do 11 | # dummy test since that's how we found the service, but oh well 12 | @service.url.should == '/services/test.xml' 13 | end 14 | 15 | it "should have some http verbs defined" do 16 | @service.verb.should == :get 17 | end 18 | 19 | it "should have supported formats defined" do 20 | @service.formats.should == [:xml, :json] 21 | end 22 | 23 | it "should have params info" do 24 | @service.params.should be_an_instance_of(WeaselDiesel::Params) 25 | end 26 | 27 | it "should have direct access to the required params" do 28 | @service.required_rules.should == @service.params.list_required 29 | end 30 | 31 | it "should have direct access to the optional params" do 32 | @service.optional_rules.should == @service.params.list_optional 33 | end 34 | 35 | it "should have direct access to the nested params" do 36 | @service.nested_params.should == @service.params.namespaced_params 37 | end 38 | 39 | it "should have some documentation" do 40 | @service.doc.should be_an_instance_of(WeaselDiesel::Documentation) 41 | end 42 | 43 | it "should store urls with a leading slash" do 44 | service = WeaselDiesel.new("/foo") 45 | service.url.should == "/foo" 46 | service.url.should == WeaselDiesel.new("foo").url 47 | WeaselDiesel.new("foo").url.should_not == WeaselDiesel.new("foo/").url 48 | 49 | root = WeaselDiesel.new("/") 50 | root.url.should == "/" 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /spec/ws_list_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe WSList do 4 | 5 | it "find service by verb/route" do 6 | service = WSList.find(:get, 'services/test.xml') 7 | service.should_not be_nil 8 | service.url.should == '/services/test.xml' 9 | service.verb.should == :get 10 | 11 | service = WSList.find(:delete, 'services/test.xml') 12 | service.url.should == '/services/test.xml' 13 | service.verb.should == :delete 14 | end 15 | 16 | it "finds service without or without the leading slash" do 17 | service = WSList.find(:get, '/services/test.xml') 18 | service.should_not be_nil 19 | service.url.should == '/services/test.xml' 20 | 21 | service = WSList.find(:delete, '/services/test.xml') 22 | service.url.should == '/services/test.xml' 23 | 24 | service = WSList.find(:get, 'slash/foo') 25 | service.should_not be_nil 26 | service.url.should == "/slash/foo" 27 | end 28 | 29 | it "finds the root service" do 30 | service = WSList.find(:get, '/') 31 | service.should_not be_nil 32 | service.extra["name"].should == "root" 33 | end 34 | 35 | 36 | it "raises an exception if a duplicate service is added" do 37 | lambda{ WSList.add(WeaselDiesel.new("/")) }.should raise_exception(WSList::DuplicateServiceDescription) 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /weasel_diesel.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "weasel_diesel/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "weasel_diesel" 7 | s.version = WeaselDiesel::VERSION 8 | s.authors = ["Matt Aimonetti"] 9 | s.email = ["mattaimonetti@gmail.com"] 10 | s.homepage = "https://github.com/mattetti/Weasel-Diesel" 11 | s.summary = %q{Web Service DSL} 12 | s.description = %q{Ruby DSL describing Web Services without implementation details.} 13 | s.license = 'MIT' 14 | 15 | s.rubyforge_project = "wsdsl" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | 22 | s.add_dependency('thor') 23 | 24 | # specify any dependencies here; for example: 25 | s.add_development_dependency "rspec" 26 | s.add_development_dependency "rack-test" 27 | s.add_development_dependency "yard" 28 | s.add_development_dependency "rake" 29 | end 30 | --------------------------------------------------------------------------------