interpol 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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