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 +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
|