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 +80 -3
- data/Rakefile +1 -1
- data/lib/interpol/configuration.rb +14 -3
- data/lib/interpol/define_singleton_method.rb +10 -0
- data/lib/interpol/documentation_app/views/endpoint.erb +7 -9
- data/lib/interpol/documentation_app.rb +1 -1
- data/lib/interpol/dynamic_struct.rb +37 -0
- data/lib/interpol/each_with_object.rb +9 -0
- data/lib/interpol/endpoint.rb +53 -15
- data/lib/interpol/request_params_parser.rb +263 -0
- data/lib/interpol/sinatra/request_params_parser.rb +102 -0
- data/lib/interpol/stub_app.rb +1 -1
- data/lib/interpol/test_helper.rb +33 -12
- data/lib/interpol/version.rb +1 -1
- metadata +9 -4
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
|
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
|
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
@@ -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.
|
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 |
|
7
|
-
|
8
|
-
<
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
+
|
data/lib/interpol/endpoint.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
@
|
75
|
-
if x.
|
76
|
-
y.
|
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.
|
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
|
-
|
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 :
|
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
|
-
|
135
|
-
|
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 =
|
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
|
+
|
data/lib/interpol/stub_app.rb
CHANGED
data/lib/interpol/test_helper.rb
CHANGED
@@ -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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
22
|
+
def each_definition_from(endpoints)
|
21
23
|
endpoints.each do |endpoint|
|
22
|
-
endpoint.definitions.each do |
|
23
|
-
|
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)
|
data/lib/interpol/version.rb
CHANGED
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.
|
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-
|
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:
|
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:
|
235
|
+
hash: 2719553250962726743
|
231
236
|
requirements: []
|
232
237
|
rubyforge_project:
|
233
238
|
rubygems_version: 1.8.24
|