interpol 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -22,6 +22,9 @@ definitions:
22
22
  ensure that your real API returns valid responses.
23
23
  * `Interpol::DocumentationApp` builds a sinatra app that renders
24
24
  documentation for your API based on the endpoint definitions.
25
+ * `Interpol::Sinatra::RequestParamsParser` validates and parses
26
+ a sinatra `params` hash based on your endpoint params schema
27
+ definitions.
25
28
 
26
29
  You can use any of these tools individually or some combination of all
27
30
  of them.
@@ -51,6 +54,15 @@ name: user_projects
51
54
  route: /users/:user_id/projects
52
55
  method: GET
53
56
  definitions:
57
+ - message_type: request
58
+ versions: ["1.0"]
59
+ path_params:
60
+ type: object
61
+ properties:
62
+ user_id:
63
+ type: integer
64
+ schema: {}
65
+ examples: []
54
66
  - message_type: response
55
67
  versions: ["1.0"]
56
68
  status_codes: ["2xx", "404"]
@@ -107,6 +119,9 @@ Let's look at this YAML file, point-by-point:
107
119
  attribute that defaults to all status codes. Valid formats for a status code are 3
108
120
  characters. Each character must be a digit (0-9) or 'x' (wildcard). The following strings
109
121
  are all valid: "200", "4xx", "x0x".
122
+ * `path_params` lists the path parameters that are used by a request to
123
+ this endpoint. You can also list `query_params` in the same manner.
124
+ These are both used by `Interpol::Sinatra::RequestParamsParser`.
110
125
  * The `schema` contains a [JSON schema](http://tools.ietf.org/html/draft-zyp-json-schema-03)
111
126
  description of the contents of the endpoint. This schema definition is used by the
112
127
  `SchemaValidation` middleware to ensure that your implementation of the endpoint
@@ -136,14 +151,15 @@ Interpol.default_configuration do |config|
136
151
  # This is useful when you need to extract the version from a
137
152
  # request header (e.g. Accept) or from the request URI.
138
153
  #
139
- # Needed by Interpol::StubApp and Interpol::ResponseSchemaValidator.
154
+ # Needed by Interpol::StubApp, Interpol::ResponseSchemaValidator
155
+ # and Interpol::Sinatra::RequestParamsParser.
140
156
  config.api_version '1.0'
141
157
 
142
158
  # Determines the stub app response when the requested version is not
143
- # available. This block will be eval'd in the context of the stub app
159
+ # available. This block will be eval'd in the context of a
144
160
  # sinatra application, so you can use sinatra helpers like `halt` here.
145
161
  #
146
- # Needed by Interpol::StubApp.
162
+ # Needed by Interpol::StubApp and Interpol::Sinatra::RequestParamsParser.
147
163
  config.on_unavailable_request_version do |requested_version, available_versions|
148
164
  message = JSON.dump(
149
165
  "message" => "Not Acceptable",
@@ -189,6 +205,17 @@ Interpol.default_configuration do |config|
189
205
  config.filter_example_data do |example, request_env|
190
206
  example.data["current_url"] = Rack::Request.new(request_env).url
191
207
  end
208
+
209
+ # Determines what to do when Interpol::Sinatra::RequestParamsParser
210
+ # detects invalid path or query parameters based on their schema
211
+ # definitions. This block will be eval'd in the context of your
212
+ # sinatra application so you can use any helper methods such as
213
+ # `halt`.
214
+ #
215
+ # Used by Interpol::Sinatra::RequestParamsParser.
216
+ config.on_invalid_sinatra_request_params do |error|
217
+ halt 400, JSON.dump(:error => error.message)
218
+ end
192
219
  end
193
220
 
194
221
  ```
@@ -307,6 +334,56 @@ run doc_app
307
334
  Note: the documentation app is definitely a work-in-progress and I'm not
308
335
  a front-end/UI developer. I'd happily accept a pull request improving it!
309
336
 
337
+ ### Interpol::Sinatra::RequestParamsParser
338
+
339
+ This Sinatra middleware does a few things:
340
+
341
+ * It validates the path and query params according to the schema
342
+ definitions in your YAML files.
343
+ * It replaces the `params` hash with an object that:
344
+ * Exposes a method for each defined parameter--so you can use
345
+ `params.user_id` rather than `params[:user_id]`. Undefined
346
+ params will raise a `NoMethodError` rather than getting `nil`
347
+ as you would with the normal params hash.
348
+ * Exposes a predicate method for each defined parameter -- so
349
+ you can use `params.user_id?` in a conditional rather than
350
+ `params.user_id`.
351
+ * Parses each parameter value into an appropriate object based on
352
+ the defined schema:
353
+ * An `integer` param will be exposed as a `Fixnum`.
354
+ * A `number` param will be exposed as a `Float`.
355
+ * A `null` param will be exposed as `nil` (rather than the empty
356
+ string).
357
+ * A `boolean` param will be exposed as `true` or `false` (rather
358
+ than the corresponding strings).
359
+ * A `string` param with a `date` format will be exposed as a `Date`.
360
+ * A `string` param with a `date-time` format will be exposed as a `Time`.
361
+ * A `string` param with a `uri` format will be exposed as `URI`.
362
+ * Anything that cannot be parsed into an object will be exposed as
363
+ its original `string` value.
364
+ * It exposes the original params hash as `unparsed_params`.
365
+
366
+ Usage:
367
+
368
+ ``` ruby
369
+ require 'sinatra/base'
370
+ require 'interpol/sinatra/request_params_parser'
371
+
372
+ class MySinatraApp < Sinatra::Base
373
+ # The block is only necessary if you want to override the
374
+ # default config or have not set a default config.
375
+ use Interpol::Sinatra::RequestParamsParser do |config|
376
+ config.on_invalid_sinatra_request_params do |error|
377
+ halt 400, JSON.dump(:error => error.message)
378
+ end
379
+ end
380
+
381
+ get '/users/:user_id' do
382
+ JSON.dump User.find(params.user_id)
383
+ end
384
+ end
385
+ ```
386
+
310
387
  ## Contributing
311
388
 
312
389
  1. Fork it
data/Rakefile CHANGED
@@ -17,7 +17,7 @@ if defined?(RUBY_ENGINE) && RUBY_ENGINE == 'ruby' # MRI only
17
17
  cane.style_measure = 100
18
18
 
19
19
  cane.abc_exclude = %w[
20
- Interpol::Endpoint#definitions
20
+ Interpol::Configuration#register_default_callbacks
21
21
  ]
22
22
  end
23
23
  else
@@ -18,10 +18,9 @@ module Interpol
18
18
  end
19
19
 
20
20
  private
21
+
21
22
  def find_definitions_for(endpoint, version, message_type)
22
- endpoint.definitions.find do |d|
23
- d.first.version == version && d.first.message_type == message_type
24
- end || []
23
+ endpoint.find_definition(version, message_type) { [] }
25
24
  end
26
25
 
27
26
  def with_endpoint_matching(method, path)
@@ -86,6 +85,14 @@ module Interpol
86
85
  execution_context.instance_exec(*args, &@unavailable_request_version_block)
87
86
  end
88
87
 
88
+ def on_invalid_sinatra_request_params(&block)
89
+ @invalid_sinatra_request_params_block = block
90
+ end
91
+
92
+ def sinatra_request_params_invalid(execution_context, *args)
93
+ execution_context.instance_exec(*args, &@invalid_sinatra_request_params_block)
94
+ end
95
+
89
96
  def filter_example_data(&block)
90
97
  filter_example_data_blocks << block
91
98
  end
@@ -145,6 +152,10 @@ module Interpol
145
152
  "Available: #{available}"
146
153
  halt 406, JSON.dump(:error => message)
147
154
  end
155
+
156
+ on_invalid_sinatra_request_params do |error|
157
+ halt 400, JSON.dump(:error => error.message)
158
+ end
148
159
  end
149
160
  end
150
161
  end
@@ -0,0 +1,10 @@
1
+ module Interpol
2
+ # 1.9 has Object#define_singleton_method but 1.8 does not.
3
+ # This provides 1.8 compatibility for the places we need it.
4
+ module DefineSingletonMethod
5
+ def define_singleton_method(name, &block)
6
+ (class << self; self; end).send(:define_method, name, &block)
7
+ end
8
+ end
9
+ end
10
+
@@ -3,15 +3,13 @@
3
3
  <h1><%= endpoint.name %></h1>
4
4
  <h2><%= endpoint.method.to_s.upcase %> <%= endpoint.route %></h2>
5
5
 
6
- <% endpoint.definitions.each do |definitions| %>
7
- <% definitions.each do |definition| %>
8
- <div class="versioned-endpoint-definition">
9
- <h3><%= definition.message_type.capitalize %> - Version <%= definition.version %> - <%= definition.status_codes %></h3>
10
- <br/>
11
- <%= Interpol::Documentation.html_for_schema(definition.schema) %>
12
- </div>
13
- <hr/>
14
- <% end %>
6
+ <% endpoint.definitions.each do |definition| %>
7
+ <div class="versioned-endpoint-definition">
8
+ <h3><%= definition.message_type.capitalize %> - Version <%= definition.version %> - <%= definition.status_codes %></h3>
9
+ <br/>
10
+ <%= Interpol::Documentation.html_for_schema(definition.schema) %>
11
+ </div>
12
+ <hr/>
15
13
  <% end %>
16
14
  </div>
17
15
  </div><!--/span-->
@@ -94,7 +94,7 @@ module Interpol
94
94
  attr_reader :app
95
95
 
96
96
  def initialize(config)
97
- @app = Sinatra.new do
97
+ @app = ::Sinatra.new do
98
98
  dir = File.dirname(File.expand_path(__FILE__))
99
99
  set :views, "#{dir}/documentation_app/views"
100
100
  set :public_folder, "#{dir}/documentation_app/public"
@@ -0,0 +1,37 @@
1
+ require 'interpol/define_singleton_method' unless Object.method_defined?(:define_singleton_method)
2
+
3
+ module Interpol
4
+ # Transforms an arbitrarily deeply nested hash into a dot-syntax
5
+ # object. Useful as an alternative to a hash since it is "strongly typed"
6
+ # in the sense that fat-fingered property names result in a NoMethodError,
7
+ # rather than getting a nil as you would with a hash.
8
+ class DynamicStruct
9
+ attr_reader :attribute_names, :to_hash
10
+
11
+ def initialize(hash)
12
+ @to_hash = hash
13
+ @attribute_names = hash.keys.map(&:to_sym)
14
+
15
+ hash.each do |key, value|
16
+ value = method_value_for(value)
17
+ define_singleton_method(key) { value }
18
+ define_singleton_method("#{key}?") { !!value }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def method_value_for(hash_value)
25
+ return self.class.new(hash_value) if hash_value.is_a?(Hash)
26
+
27
+ if hash_value.is_a?(Array) && hash_value.all? { |v| v.is_a?(Hash) }
28
+ return hash_value.map { |v| self.class.new(v) }
29
+ end
30
+
31
+ hash_value
32
+ end
33
+
34
+ include DefineSingletonMethod unless method_defined?(:define_singleton_method)
35
+ end
36
+ end
37
+
@@ -0,0 +1,9 @@
1
+ # 1.9 has Enumerable#each_with_object, but 1.8 does not.
2
+ # This provides 1.8 compat for the places where we use each_with_object.
3
+ module Enumerable
4
+ def each_with_object(object)
5
+ each { |item| yield item, object }
6
+ object
7
+ end
8
+ end
9
+
@@ -1,5 +1,6 @@
1
1
  require 'json-schema'
2
2
  require 'interpol/errors'
3
+ require 'forwardable'
3
4
 
4
5
  module JSON
5
6
  # The JSON-schema namespace
@@ -44,18 +45,24 @@ module Interpol
44
45
  @name = fetch_from(endpoint_hash, 'name')
45
46
  @route = fetch_from(endpoint_hash, 'route')
46
47
  @method = fetch_from(endpoint_hash, 'method').downcase.to_sym
47
- @definitions = extract_definitions_from(endpoint_hash)
48
+
49
+ @definitions_hash, @all_definitions = extract_definitions_from(endpoint_hash)
50
+
48
51
  validate_name!
49
52
  end
50
53
 
51
54
  def find_definition!(version, message_type)
52
- @definitions.fetch([message_type, version]) do
55
+ find_definition(version, message_type) do
53
56
  message = "No definition found for #{name} endpoint for version #{version}"
54
57
  message << " and message_type #{message_type}"
55
58
  raise NoEndpointDefinitionFoundError.new(message)
56
59
  end
57
60
  end
58
61
 
62
+ def find_definition(version, message_type, &block)
63
+ @definitions_hash.fetch([message_type, version], &block)
64
+ end
65
+
59
66
  def find_example_for!(version, message_type)
60
67
  find_definition!(version, message_type).first.examples.first
61
68
  end
@@ -65,17 +72,19 @@ module Interpol
65
72
  end
66
73
 
67
74
  def available_versions
68
- definitions.map { |d| d.first.version }
75
+ @all_definitions.inject(Set.new) do |set, definition|
76
+ set << definition.version
77
+ end.to_a
69
78
  end
70
79
 
71
80
  def definitions
72
81
  # sort all requests before all responses
73
82
  # sort higher version numbers before lower version numbers
74
- @definitions.values.sort do |x,y|
75
- if x.first.message_type == y.first.message_type
76
- y.first.version <=> x.first.version
83
+ @sorted_definitions ||= @all_definitions.sort do |x, y|
84
+ if x.message_type == y.message_type
85
+ y.version <=> x.version
77
86
  else
78
- x.first.message_type <=> y.first.message_type
87
+ x.message_type <=> y.message_type
79
88
  end
80
89
  end
81
90
  end
@@ -104,16 +113,19 @@ module Interpol
104
113
 
105
114
  def extract_definitions_from(endpoint_hash)
106
115
  definitions = Hash.new { |h, k| h[k] = [] }
116
+ all_definitions = []
107
117
 
108
118
  fetch_from(endpoint_hash, 'definitions').each do |definition|
109
119
  fetch_from(definition, 'versions').each do |version|
110
120
  message_type = definition.fetch('message_type', DEFAULT_MESSAGE_TYPE)
111
121
  key = [message_type, version]
112
- definitions[key] << EndpointDefinition.new(name, version, message_type, definition)
122
+ endpoint_definition = EndpointDefinition.new(self, version, message_type, definition)
123
+ definitions[key] << endpoint_definition
124
+ all_definitions << endpoint_definition
113
125
  end
114
126
  end
115
127
 
116
- definitions
128
+ return definitions, all_definitions
117
129
  end
118
130
 
119
131
  def validate_name!
@@ -128,21 +140,33 @@ module Interpol
128
140
  # Provides the means to validate data against that version of the schema.
129
141
  class EndpointDefinition
130
142
  include HashFetcher
131
- attr_reader :endpoint_name, :message_type, :version, :schema,
143
+ attr_reader :endpoint, :message_type, :version, :schema,
132
144
  :path_params, :query_params, :examples
145
+ extend Forwardable
146
+ def_delegators :endpoint, :route
133
147
 
134
- def initialize(endpoint_name, version, message_type, definition)
135
- @endpoint_name = endpoint_name
148
+ DEFAULT_PARAM_HASH = { 'type' => 'object', 'properties' => {} }
149
+
150
+ def initialize(endpoint, version, message_type, definition)
151
+ @endpoint = endpoint
136
152
  @message_type = message_type
137
153
  @status_codes = StatusCodeMatcher.new(definition['status_codes'])
138
154
  @version = version
139
155
  @schema = fetch_from(definition, 'schema')
140
- @path_params = definition.fetch('path_params', {})
141
- @query_params = definition.fetch('query_params', {})
142
- @examples = fetch_from(definition, 'examples').map { |e| EndpointExample.new(e, self) }
156
+ @path_params = definition.fetch('path_params', DEFAULT_PARAM_HASH.dup)
157
+ @query_params = definition.fetch('query_params', DEFAULT_PARAM_HASH.dup)
158
+ @examples = extract_examples_from(definition)
143
159
  make_schema_strict!(@schema)
144
160
  end
145
161
 
162
+ def request?
163
+ message_type == "request"
164
+ end
165
+
166
+ def endpoint_name
167
+ @endpoint.name
168
+ end
169
+
146
170
  def validate_data!(data)
147
171
  errors = ::JSON::Validator.fully_validate_schema(schema)
148
172
  raise ValidationError.new(errors, nil, description) if errors.any?
@@ -166,6 +190,14 @@ module Interpol
166
190
  @example_status_code ||= @status_codes.example_status_code
167
191
  end
168
192
 
193
+ def parse_request_params(request_params)
194
+ request_params_parser.parse(request_params)
195
+ end
196
+
197
+ def request_params_parser
198
+ @request_params_parser ||= RequestParamsParser.new(self)
199
+ end
200
+
169
201
  private
170
202
 
171
203
  def make_schema_strict!(raw_schema, modify_object=true)
@@ -180,6 +212,12 @@ module Interpol
180
212
  raw_schema['additionalProperties'] ||= false
181
213
  raw_schema['required'] = !raw_schema.delete('optional')
182
214
  end
215
+
216
+ def extract_examples_from(definition)
217
+ fetch_from(definition, 'examples').map do |ex|
218
+ EndpointExample.new(ex, self)
219
+ end
220
+ end
183
221
  end
184
222
 
185
223
  # Holds the acceptable status codes for an enpoint entry
@@ -0,0 +1,263 @@
1
+ require 'interpol'
2
+ require 'interpol/dynamic_struct'
3
+ require 'uri'
4
+ require 'interpol/each_with_object' unless Enumerable.method_defined?(:each_with_object)
5
+
6
+ module Interpol
7
+ # This class is designed to parse and validate a rails or sinatra
8
+ # style params hash based on the path_params/query_params
9
+ # declarations of an endpoint request definition.
10
+ #
11
+ # The path_params and query_params declarations are merged together
12
+ # during validation, since both rails and sinatra give users a single
13
+ # params hash that contains the union of both kinds of params.
14
+ #
15
+ # Note that the validation here takes some liberties; it supports
16
+ # '3' as well as 3 for 'integer' params, for example, because it
17
+ # assumes that the values in a params hash are almost certainly going
18
+ # to be strings since that's how rails and sinatra give them to you.
19
+ #
20
+ # The parsed params object supports dot-syntax for accessing parameters
21
+ # and will convert values where feasible (e.g. '3' = 3, 'true' => true, etc).
22
+ class RequestParamsParser
23
+ def initialize(endpoint_definition)
24
+ @validator = ParamValidator.new(endpoint_definition)
25
+ @validator.validate_path_params_valid_for_route!
26
+ @converter = ParamConverter.new(@validator.param_definitions)
27
+ end
28
+
29
+ def parse(params)
30
+ validate!(params)
31
+ DynamicStruct.new(@converter.convert params)
32
+ end
33
+
34
+ def validate!(params)
35
+ @validator.validate!(params)
36
+ end
37
+
38
+ # Private: This takes care of the validation.
39
+ class ParamValidator
40
+ def initialize(endpoint_definition)
41
+ @endpoint_definition = endpoint_definition
42
+ @params_schema = build_params_schema
43
+ end
44
+
45
+ def validate_path_params_valid_for_route!
46
+ route = @endpoint_definition.route
47
+ invalid_params = property_defs_from(:path_params).keys.reject do |param|
48
+ route =~ %r</:#{Regexp.escape(param)}(/|$)>
49
+ end
50
+
51
+ return if invalid_params.none?
52
+ raise InvalidPathParamsError.new(*invalid_params)
53
+ end
54
+
55
+ def validate!(params)
56
+ errors = ::JSON::Validator.fully_validate(@params_schema, params)
57
+ raise ValidationError.new(errors, params, description) if errors.any?
58
+ end
59
+
60
+ def param_definitions
61
+ @param_definitions ||= property_defs_from(:path_params).merge \
62
+ property_defs_from(:query_params)
63
+ end
64
+
65
+ private
66
+
67
+ def description
68
+ @description ||= "#{@endpoint_definition.description} - request params"
69
+ end
70
+
71
+ def property_defs_from(meth)
72
+ schema = @endpoint_definition.send(meth)
73
+
74
+ unless schema['type'] == 'object'
75
+ raise InvalidParamsDefinitionError,
76
+ "The #{meth} of #{@endpoint_definition.description} " +
77
+ "is not typed as an object expected."
78
+ end
79
+
80
+ schema.fetch('properties') do
81
+ raise InvalidParamsDefinitionError,
82
+ "The #{meth} of #{@endpoint_definition.description} " +
83
+ "does not contain 'properties' as required."
84
+ end
85
+ end
86
+
87
+ def build_params_schema
88
+ path_params = @endpoint_definition.path_params
89
+ query_params = @endpoint_definition.query_params
90
+
91
+ query_params.merge(path_params).tap do |schema|
92
+ schema['properties'] = adjusted_definitions
93
+ schema['additionalProperties'] = false if no_additional_properties?
94
+ end
95
+ end
96
+
97
+ def adjusted_definitions
98
+ param_definitions.each_with_object({}) do |(name, schema), hash|
99
+ hash[name] = adjusted_schema(schema)
100
+ end
101
+ end
102
+
103
+ def no_additional_properties?
104
+ [
105
+ @endpoint_definition.path_params,
106
+ @endpoint_definition.query_params
107
+ ].none? { |params| params['additionalProperties'] }
108
+ end
109
+
110
+ STRING_EQUIVALENTS = {
111
+ 'string' => nil,
112
+ 'integer' => { 'type' => 'string', 'pattern' => '^\-?\d+$' },
113
+ 'number' => { 'type' => 'string', 'pattern' => '^\-?\d+(\.\d+)?$' },
114
+ 'boolean' => { 'type' => 'string', 'enum' => %w[ true false ] },
115
+ 'null' => { 'type' => 'string', 'enum' => [''] }
116
+ }
117
+
118
+ def adjusted_schema(schema)
119
+ types = Array(schema['type'])
120
+
121
+ string_equivalents = types.map do |type|
122
+ STRING_EQUIVALENTS.fetch(type) do
123
+ unless type.is_a?(Hash) # a nested union type
124
+ raise UnsupportedTypeError.new(type)
125
+ end
126
+ end
127
+ end.compact
128
+
129
+ schema.merge('type' => (types + string_equivalents)).tap do |adjusted|
130
+ adjusted['required'] = true unless adjusted['optional']
131
+ end
132
+ end
133
+ end
134
+
135
+ # Private: This takes care of the parameter conversions.
136
+ class ParamConverter
137
+ attr_reader :param_definitions
138
+
139
+ def initialize(param_definitions)
140
+ @param_definitions = param_definitions
141
+ end
142
+
143
+ def convert(params)
144
+ @param_definitions.keys.each_with_object({}) do |name, hash|
145
+ hash[name] = if params.has_key?(name)
146
+ convert_param(name, params.fetch(name))
147
+ else
148
+ nil
149
+ end
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def convert_param(name, value)
156
+ definition = param_definitions.fetch(name)
157
+
158
+ Array(definition['type']).each do |type|
159
+ converter = converter_for(type, definition)
160
+
161
+ begin
162
+ return converter.call(value)
163
+ rescue ArgumentError => e
164
+ # Try the next unioned type...
165
+ end
166
+ end
167
+
168
+ raise CannotBeParsedError, "The #{name} #{value.inspect} cannot be parsed"
169
+ end
170
+
171
+ BOOLEANS = { 'true' => true, true => true,
172
+ 'false' => false, false => false }
173
+ def self.convert_boolean(value)
174
+ BOOLEANS.fetch(value) do
175
+ raise ArgumentError, "#{value} is not convertable to a boolean"
176
+ end
177
+ end
178
+
179
+ NULLS = { '' => nil, nil => nil }
180
+ def self.convert_null(value)
181
+ NULLS.fetch(value) do
182
+ raise ArgumentError, "#{value} is not convertable to null"
183
+ end
184
+ end
185
+
186
+ def self.convert_date(value)
187
+ unless value =~ /\A\d{4}\-\d{2}\-\d{2}\z/
188
+ raise ArgumentError, "Not in iso8601 format"
189
+ end
190
+
191
+ Date.new(*value.split('-').map(&:to_i))
192
+ end
193
+
194
+ def self.convert_uri(value)
195
+ URI(value).tap do |uri|
196
+ unless uri.scheme && uri.host
197
+ raise ArgumentError, "Not a valid full URI"
198
+ end
199
+ end
200
+ rescue URI::InvalidURIError => e
201
+ raise ArgumentError, e.message, e.backtrace
202
+ end
203
+
204
+ CONVERTERS = {
205
+ 'integer' => method(:Integer),
206
+ 'number' => method(:Float),
207
+ 'boolean' => method(:convert_boolean),
208
+ 'null' => method(:convert_null)
209
+ }
210
+
211
+ IDENTITY_CONVERTER = lambda { |v| v }
212
+
213
+ def converter_for(type, definition)
214
+ CONVERTERS.fetch(type) do
215
+ if Hash === type && type['type']
216
+ converter_for(type['type'], type)
217
+ elsif type == 'string'
218
+ string_converter_for(definition)
219
+ else
220
+ raise CannotBeParsedError, "#{type} cannot be parsed"
221
+ end
222
+ end
223
+ end
224
+
225
+ STRING_CONVERTERS = {
226
+ 'date' => method(:convert_date),
227
+ 'date-time' => Time.method(:iso8601),
228
+ 'uri' => method(:convert_uri)
229
+ }
230
+
231
+ def string_converter_for(definition)
232
+ STRING_CONVERTERS.fetch(definition['format'], IDENTITY_CONVERTER)
233
+ end
234
+ end
235
+
236
+ # Raised when an unsupported parameter type is defined.
237
+ class UnsupportedTypeError < ArgumentError
238
+ attr_reader :type
239
+
240
+ def initialize(type)
241
+ @type = type
242
+ super("#{type} params are not supported")
243
+ end
244
+ end
245
+
246
+ # Raised when the path_params are not part of the endpoint route.
247
+ class InvalidPathParamsError < ArgumentError
248
+ attr_reader :invalid_params
249
+
250
+ def initialize(*invalid_params)
251
+ @invalid_params = invalid_params
252
+ super("The path params #{invalid_params.join(', ')} are not in the route")
253
+ end
254
+ end
255
+
256
+ # Raised when a parameter value cannot be parsed.
257
+ CannotBeParsedError = Class.new(ArgumentError)
258
+
259
+ # Raised when a params definition is invalid.
260
+ InvalidParamsDefinitionError = Class.new(ArgumentError)
261
+ end
262
+ end
263
+
@@ -0,0 +1,102 @@
1
+ require 'interpol/request_params_parser'
2
+
3
+ module Interpol
4
+ module Sinatra
5
+ # Parses and validates a sinatra params hash based on the
6
+ # endpoint definitions.
7
+ # Note that you use this like a sinatra middleware
8
+ # (using a `use` directive in the body of the sinatra class), but
9
+ # it hooks into sinatra differently so that it has access to the params.
10
+ # It's more like a mix-in, honestly, but we piggyback on `use` so that
11
+ # it can take a config block.
12
+ class RequestParamsParser
13
+ def initialize(app, &block)
14
+ @app = app
15
+ hook_into(app, &block)
16
+ end
17
+
18
+ def call(env)
19
+ @app.call(env)
20
+ end
21
+
22
+ private
23
+
24
+ def hook_into(app, &block)
25
+ return if defined?(app.settings.interpol_config)
26
+ config = Configuration.default.customized_duplicate(&block)
27
+
28
+ app.class.class_eval do
29
+ alias unparsed_params params
30
+ helpers SinatraHelpers
31
+ set :interpol_config, config
32
+ enable :parse_params unless settings.respond_to?(:parse_params)
33
+ include SinatraOverriddes
34
+ end
35
+ end
36
+
37
+ module SinatraHelpers
38
+ # Make the config available at the instance level for convenience.
39
+ def interpol_config
40
+ self.class.interpol_config
41
+ end
42
+
43
+ def endpoint_definition
44
+ @endpoint_definition ||= begin
45
+ version = available_versions = nil
46
+
47
+ definition = interpol_config.endpoints.find_definition \
48
+ env.fetch('REQUEST_METHOD'), request.path, 'request', nil do |endpoint|
49
+ available_versions ||= endpoint.available_versions
50
+ interpol_config.api_version_for(env, endpoint).tap do |_version|
51
+ version ||= _version
52
+ end
53
+ end
54
+
55
+ if definition == DefinitionFinder::NoDefinitionFound
56
+ interpol_config.request_version_unavailable(self, version, available_versions)
57
+ end
58
+
59
+ definition
60
+ end
61
+ end
62
+
63
+ def params
64
+ @_parsed_params || super
65
+ end
66
+
67
+ def validate_params
68
+ @_parsed_params = endpoint_definition.parse_request_params(params_to_parse)
69
+ rescue Interpol::ValidationError => error
70
+ request_params_invalid(error)
71
+ end
72
+
73
+ def request_params_invalid(error)
74
+ interpol_config.sinatra_request_params_invalid(self, error)
75
+ end
76
+
77
+ # Sinatra includes a couple of "meta" params that are always
78
+ # present in the params hash even though they are not declared
79
+ # as params: splat and captures.
80
+ def params_to_parse
81
+ unparsed_params.dup.tap do |p|
82
+ p.delete('splat')
83
+ p.delete('captures')
84
+ end
85
+ end
86
+ end
87
+
88
+ module SinatraOverriddes
89
+ # We cannot access the full params (w/ path params) in a before hook,
90
+ # due to the order that sinatra runs the hooks in relation to route
91
+ # matching.
92
+ def process_route(*method_args)
93
+ super do |*block_args|
94
+ validate_params if settings.parse_params?
95
+ yield *block_args
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
@@ -33,7 +33,7 @@ module Interpol
33
33
  attr_reader :app
34
34
 
35
35
  def initialize(config)
36
- @app = Sinatra.new do
36
+ @app = ::Sinatra.new do
37
37
  set :interpol_config, config
38
38
  helpers Helpers
39
39
  not_found { JSON.dump(:error => "The requested resource could not be found") }
@@ -1,5 +1,6 @@
1
1
  require 'interpol'
2
2
  require 'rack/mock'
3
+ require 'interpol/request_params_parser'
3
4
 
4
5
  module Interpol
5
6
  module TestHelper
@@ -7,28 +8,48 @@ module Interpol
7
8
  def define_interpol_example_tests(&block)
8
9
  config = Configuration.default.customized_duplicate(&block)
9
10
 
10
- each_example_from(config.endpoints) do |endpoint, definition, example, example_index|
11
- description = "#{endpoint.name} (v #{definition.version}) has " +
12
- "valid data for example #{example_index + 1}"
13
- example = filtered_example(config, endpoint, example)
14
- define_test(description) { example.validate! }
11
+ each_definition_from(config.endpoints) do |endpoint, definition|
12
+ define_definition_test(endpoint, definition)
13
+
14
+ each_example_from(definition) do |example, example_index|
15
+ define_example_test(config, endpoint, definition, example, example_index)
16
+ end
15
17
  end
16
18
  end
17
19
 
18
20
  private
19
21
 
20
- def each_example_from(endpoints)
22
+ def each_definition_from(endpoints)
21
23
  endpoints.each do |endpoint|
22
- endpoint.definitions.each do |definitions|
23
- definitions.each do |definition|
24
- definition.examples.each_with_index do |example, index|
25
- yield endpoint, definition, example, index
26
- end
27
- end
24
+ endpoint.definitions.each do |definition|
25
+ yield endpoint, definition
28
26
  end
29
27
  end
30
28
  end
31
29
 
30
+ def each_example_from(definition)
31
+ definition.examples.each_with_index do |example, index|
32
+ yield example, index
33
+ end
34
+ end
35
+
36
+ def define_example_test(config, endpoint, definition, example, example_index)
37
+ description = "#{endpoint.name} (v #{definition.version}) has " +
38
+ "valid data for example #{example_index + 1}"
39
+ example = filtered_example(config, endpoint, example)
40
+ define_test(description) { example.validate! }
41
+ end
42
+
43
+ def define_definition_test(endpoint, definition)
44
+ return unless definition.request?
45
+
46
+ description = "#{endpoint.name} (v #{definition.version}) request " +
47
+ "definition has valid params schema"
48
+ define_test description do
49
+ definition.request_params_parser # it will raise an error if it is invalid
50
+ end
51
+ end
52
+
32
53
  def filtered_example(config, endpoint, example)
33
54
  path = endpoint.route.gsub(':', '') # turn path params into static segments
34
55
  rack_env = ::Rack::MockRequest.env_for(path)
@@ -1,3 +1,3 @@
1
1
  module Interpol
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: interpol
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-09-20 00:00:00.000000000 Z
12
+ date: 2012-09-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: json-schema
@@ -169,12 +169,17 @@ files:
169
169
  - Rakefile
170
170
  - lib/interpol/configuration.rb
171
171
  - lib/interpol/configuration_ruby_18_extensions.rb
172
+ - lib/interpol/define_singleton_method.rb
172
173
  - lib/interpol/documentation.rb
173
174
  - lib/interpol/documentation_app/config.rb
174
175
  - lib/interpol/documentation_app.rb
176
+ - lib/interpol/dynamic_struct.rb
177
+ - lib/interpol/each_with_object.rb
175
178
  - lib/interpol/endpoint.rb
176
179
  - lib/interpol/errors.rb
180
+ - lib/interpol/request_params_parser.rb
177
181
  - lib/interpol/response_schema_validator.rb
182
+ - lib/interpol/sinatra/request_params_parser.rb
178
183
  - lib/interpol/stub_app.rb
179
184
  - lib/interpol/test_helper.rb
180
185
  - lib/interpol/version.rb
@@ -218,7 +223,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
218
223
  version: '0'
219
224
  segments:
220
225
  - 0
221
- hash: -521228935891374364
226
+ hash: 2719553250962726743
222
227
  required_rubygems_version: !ruby/object:Gem::Requirement
223
228
  none: false
224
229
  requirements:
@@ -227,7 +232,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
227
232
  version: '0'
228
233
  segments:
229
234
  - 0
230
- hash: -521228935891374364
235
+ hash: 2719553250962726743
231
236
  requirements: []
232
237
  rubyforge_project:
233
238
  rubygems_version: 1.8.24