interpol 0.0.1
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/Gemfile +10 -0
- data/License +22 -0
- data/README.md +263 -0
- data/Rakefile +23 -0
- data/lib/interpol/configuration.rb +101 -0
- data/lib/interpol/endpoint.rb +133 -0
- data/lib/interpol/errors.rb +28 -0
- data/lib/interpol/response_schema_validator.rb +87 -0
- data/lib/interpol/stub_app.rb +60 -0
- data/lib/interpol/test_helper.rb +44 -0
- data/lib/interpol/version.rb +3 -0
- data/lib/interpol.rb +12 -0
- metadata +149 -0
data/Gemfile
ADDED
data/License
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 SEOmoz
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,263 @@
|
|
1
|
+
# Interpol
|
2
|
+
|
3
|
+
[](http://travis-ci.org/seomoz/interpol)
|
4
|
+
|
5
|
+
Interpol is a toolkit for policing your HTTP JSON interface. To use it,
|
6
|
+
define the endpoints of your HTTP API in simple YAML files.
|
7
|
+
Interpol provides multiple tools to work with these endpoint
|
8
|
+
definitions:
|
9
|
+
|
10
|
+
* `Interpol::TestHelper::RSpec` and `Interpol::TestHelper::TestUnit` are
|
11
|
+
modules that you can mix in to your test context. They provide a means
|
12
|
+
to generate tests from your endpoint definitions that validate example
|
13
|
+
data against your JSON schema definition.
|
14
|
+
* `Interpol::StubApp` builds a stub implementation of your API from
|
15
|
+
the endpoint definitions. This can be distributed with your API's
|
16
|
+
client gem so that API users have something local to hit that
|
17
|
+
generates data that is valid according to your schema definition.
|
18
|
+
* `Interpol::ResponseSchemaValidator` is a rack middleware that
|
19
|
+
validates your API responses against the JSON schema in your endpoint
|
20
|
+
definition files. This is useful in test/development environments to
|
21
|
+
ensure that your real API returns valid responses.
|
22
|
+
* A documentation browser for your API is planned as well (but not yet
|
23
|
+
implemented).
|
24
|
+
|
25
|
+
You can use any of these tools individually or some combination of all
|
26
|
+
of them.
|
27
|
+
|
28
|
+
## Installation
|
29
|
+
|
30
|
+
Add this line to your application's Gemfile:
|
31
|
+
|
32
|
+
gem 'interpol'
|
33
|
+
|
34
|
+
And then execute:
|
35
|
+
|
36
|
+
$ bundle
|
37
|
+
|
38
|
+
Or install it yourself as:
|
39
|
+
|
40
|
+
$ gem install interpol
|
41
|
+
|
42
|
+
## Endpoint Definition
|
43
|
+
|
44
|
+
Endpoints are defined in YAML files, using a separate
|
45
|
+
file per endpoint. Here's an example:
|
46
|
+
|
47
|
+
``` yaml
|
48
|
+
---
|
49
|
+
name: user_projects
|
50
|
+
route: /users/:user_id/projects
|
51
|
+
method: GET
|
52
|
+
definitions:
|
53
|
+
- versions: ["1.0"]
|
54
|
+
schema:
|
55
|
+
description: Returns a list of projects for the given user.
|
56
|
+
type: object
|
57
|
+
properties:
|
58
|
+
projects:
|
59
|
+
description: List of projects.
|
60
|
+
type: array
|
61
|
+
items:
|
62
|
+
type: object
|
63
|
+
properties:
|
64
|
+
name:
|
65
|
+
description: The name of the project.
|
66
|
+
type: string
|
67
|
+
importance:
|
68
|
+
description: The importance of the project, on a scale of 1 to 10.
|
69
|
+
type: integer
|
70
|
+
minimum: 1
|
71
|
+
maximum: 10
|
72
|
+
|
73
|
+
examples:
|
74
|
+
- projects:
|
75
|
+
- name: iPhone App
|
76
|
+
importance: 5
|
77
|
+
- name: Rails App
|
78
|
+
importance: 7
|
79
|
+
```
|
80
|
+
|
81
|
+
Let's look at this YAML file, point-by-point:
|
82
|
+
|
83
|
+
* `name` can be anything you want. Each endpoint should have a different name. Interpol uses
|
84
|
+
it in schema validation error messages.
|
85
|
+
* `route` defines the sinatra route for this endpoint. Note that while
|
86
|
+
Interpol::StubApp supports any sinatra route, Interpol::ResponseSchemaValidator
|
87
|
+
(which has to find a matching endpoint definition from the request path), only
|
88
|
+
supports a subset of Sinatra's routing syntax. Specifically, it supports static
|
89
|
+
segments (`users` and `projects` in the example above) and named
|
90
|
+
parameter segments (`:user_id` in the example above).
|
91
|
+
* `method` defines the HTTP method for this endpoint. The method should be in uppercase.
|
92
|
+
* The `definitions` array contains a list of versioned schema definitions, with
|
93
|
+
corresponding examples. Everytime you modify your schema and change the version,
|
94
|
+
you should add a new entry here.
|
95
|
+
* The `versions` array lists the endpoint versions that should be associated with a
|
96
|
+
particular schema definition.
|
97
|
+
* The `schema` contains a [JSON schema](http://tools.ietf.org/html/draft-zyp-json-schema-03)
|
98
|
+
description of the contents of the endpoint. This schema definition is used by the
|
99
|
+
`SchemaValidation` middleware to ensure that your implementation of the endpoint
|
100
|
+
matches the definition.
|
101
|
+
* `examples` contains a list of valid example data. It is used by the stub app as example data.
|
102
|
+
|
103
|
+
## Configuration
|
104
|
+
|
105
|
+
Interpol provides two levels of configuration: global default
|
106
|
+
configuration, and one-off configuration, set on a particular
|
107
|
+
instance of one of the provided tools. Each of the tools accepts
|
108
|
+
a configuration block that provides an identical API to the
|
109
|
+
global configuration API shown below.
|
110
|
+
|
111
|
+
``` ruby
|
112
|
+
require 'interpol'
|
113
|
+
|
114
|
+
Interpol.default_configuration do |config|
|
115
|
+
# Tells Interpol where to find your endpoint definition files.
|
116
|
+
#
|
117
|
+
# Needed by all tools.
|
118
|
+
config.endpoint_definition_files = Dir["config/endpoints/*.yml"]
|
119
|
+
|
120
|
+
# Determines which versioned endpoint definition Interpol uses
|
121
|
+
# for a request. You can also use a block form, which yields
|
122
|
+
# the rack env hash and the endpoint object as arguments.
|
123
|
+
# This is useful when you need to extract the version from a
|
124
|
+
# request header (e.g. Accept) or from the request URI.
|
125
|
+
#
|
126
|
+
# Needed by Interpol::StubApp and Interpol::ResponseSchemaValidator.
|
127
|
+
config.api_version '1.0'
|
128
|
+
|
129
|
+
# Determines the stub app response when the requested version is not
|
130
|
+
# available. This block will be eval'd in the context of the stub app
|
131
|
+
# sinatra application, so you can use sinatra helpers like `halt` here.
|
132
|
+
#
|
133
|
+
# Needed by Interpol::StubApp.
|
134
|
+
config.on_unavailable_request_version do |requested_version, available_versions|
|
135
|
+
message = JSON.dump(
|
136
|
+
"message" => "Not Acceptable",
|
137
|
+
"requested_version" => requested_version,
|
138
|
+
"available_versions" => available_versions
|
139
|
+
)
|
140
|
+
|
141
|
+
halt 406, message
|
142
|
+
end
|
143
|
+
|
144
|
+
# Determines which responses will be validated against the endpoint
|
145
|
+
# definition when you use Interpol::ResponseSchemaValidator. The
|
146
|
+
# validation is meant to run against the "happy path" response.
|
147
|
+
# For responses like "404 Not Found", you probably don't want any
|
148
|
+
# validation performed. The default validate_if hook will cause
|
149
|
+
# validation to run against any 2xx response except 204 ("No Content").
|
150
|
+
#
|
151
|
+
# Used by Interpol::ResponseSchemaValidator.
|
152
|
+
config.validate_if do |env, status, headers, body|
|
153
|
+
headers['Content-Type'] == my_custom_mime_type
|
154
|
+
end
|
155
|
+
|
156
|
+
# Determines how Interpol::ResponseSchemaValidator handles
|
157
|
+
# invalid data. By default it will raise an error, but you can
|
158
|
+
# make it print a warning instead.
|
159
|
+
#
|
160
|
+
# Used by Interpol::ResponseSchemaValidator.
|
161
|
+
config.validation_mode = :error # or :warn
|
162
|
+
end
|
163
|
+
|
164
|
+
```
|
165
|
+
|
166
|
+
## Tool Usage
|
167
|
+
|
168
|
+
### Interpol::TestHelper::RSpec and Interpol::TestHelper::TestUnit
|
169
|
+
|
170
|
+
These are modules that you can extend onto an RSpec example group
|
171
|
+
or a `Test::Unit::TestCase` subclass, respectively.
|
172
|
+
They provide a `define_interpol_example_tests` macro that will define
|
173
|
+
a test for each example for each schema definition in your endpoint
|
174
|
+
definition files. The tests will validate that your schema is a valid
|
175
|
+
JSON schema definition and will validate that the examples are valid
|
176
|
+
according to that schema.
|
177
|
+
|
178
|
+
RSpec example:
|
179
|
+
|
180
|
+
``` ruby
|
181
|
+
require 'interpol/test_helper'
|
182
|
+
|
183
|
+
describe "My API endpoints" do
|
184
|
+
extend Interpol::TestHelper::RSpec
|
185
|
+
|
186
|
+
# the block is only necessary if you want to override the default
|
187
|
+
# config or if you have not set a default config.
|
188
|
+
define_interpol_example_tests do |ipol|
|
189
|
+
ipol.endpoint_definition_files = Dir["config/endpoints_definitions/*.yml"]
|
190
|
+
end
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
Test::Unit example:
|
195
|
+
|
196
|
+
``` ruby
|
197
|
+
require 'interpol/test_helper'
|
198
|
+
|
199
|
+
class MyAPIEndpointsTest < Test::Unit::TestCase
|
200
|
+
extend Interpol::TestHelper::TestUnit
|
201
|
+
define_interpol_example_tests
|
202
|
+
end
|
203
|
+
```
|
204
|
+
|
205
|
+
### Interpol::StubApp
|
206
|
+
|
207
|
+
This will build a little sinatra app that returns example data from
|
208
|
+
your endpoint definition files.
|
209
|
+
|
210
|
+
Example:
|
211
|
+
|
212
|
+
``` ruby
|
213
|
+
# config.ru
|
214
|
+
|
215
|
+
require 'interpol/stub_app'
|
216
|
+
|
217
|
+
# the block is only necessary if you want to override the default
|
218
|
+
# config or if you have not set a default config.
|
219
|
+
stub_app = Interpol::StubApp.build do |app|
|
220
|
+
app.endpoint_definition_files = Dir["config/endpoints_definitions/*.yml"]
|
221
|
+
app.api_version do |env|
|
222
|
+
RequestVersion.extract_from(env['HTTP_ACCEPT'])
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
run stub_app
|
227
|
+
```
|
228
|
+
|
229
|
+
### Interpol::ResponseSchemaValidator
|
230
|
+
|
231
|
+
This rack middleware validates the responses from your app
|
232
|
+
against the schema definition. Here's an example of how you
|
233
|
+
might use it with a class-style sinatra app:
|
234
|
+
|
235
|
+
``` ruby
|
236
|
+
require 'sinatra'
|
237
|
+
|
238
|
+
# You probably only want to validate the schema in local development.
|
239
|
+
unless ENV['RACK_ENV'] == 'production'
|
240
|
+
require 'interpol/response_schema_validator'
|
241
|
+
|
242
|
+
# the block is only necessary if you want to override the default
|
243
|
+
# config or if you have not set a default config.
|
244
|
+
use Interpol::ResponseSchemaValidator do |config|
|
245
|
+
config.endpoint_definition_files = Dir["config/endpoints_definitions/*.yml"]
|
246
|
+
config.api_version do |env|
|
247
|
+
RequestVersion.extract_from(env['HTTP_ACCEPT'])
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
get '/users/:user_id/projects' do
|
253
|
+
JSON.dump(User.find(params[:user_id]).projects)
|
254
|
+
end
|
255
|
+
```
|
256
|
+
|
257
|
+
## Contributing
|
258
|
+
|
259
|
+
1. Fork it
|
260
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
261
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
262
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
263
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
6
|
+
t.rspec_opts = %w[--profile --format progress]
|
7
|
+
t.ruby_opts = "-Ispec -rsimplecov_setup"
|
8
|
+
end
|
9
|
+
|
10
|
+
if RUBY_ENGINE == 'ruby' # MRI only
|
11
|
+
require 'cane/rake_task'
|
12
|
+
|
13
|
+
desc "Run cane to check quality metrics"
|
14
|
+
Cane::RakeTask.new(:quality) do |cane|
|
15
|
+
cane.abc_max = 12
|
16
|
+
cane.add_threshold 'coverage/coverage_percent.txt', :==, 100
|
17
|
+
cane.style_measure = 100
|
18
|
+
end
|
19
|
+
else
|
20
|
+
task(:quality) { } # no-op
|
21
|
+
end
|
22
|
+
|
23
|
+
task default: [:spec, :quality]
|
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'interpol/endpoint'
|
2
|
+
require 'interpol/errors'
|
3
|
+
require 'yaml'
|
4
|
+
|
5
|
+
module Interpol
|
6
|
+
module DefinitionFinder
|
7
|
+
include HashFetcher
|
8
|
+
NoDefinitionFound = Class.new
|
9
|
+
|
10
|
+
def find_definition(method, path)
|
11
|
+
with_endpoint_matching(method, path) do |endpoint|
|
12
|
+
version = yield endpoint
|
13
|
+
endpoint.definitions.find { |d| d.version == version }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def with_endpoint_matching(method, path)
|
20
|
+
method = method.downcase.to_sym
|
21
|
+
endpoint = find { |e| e.method == method && e.route_matches?(path) }
|
22
|
+
(yield endpoint if endpoint) || NoDefinitionFound
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Public: Defines interpol configuration.
|
27
|
+
class Configuration
|
28
|
+
attr_reader :endpoint_definition_files, :endpoints
|
29
|
+
attr_accessor :validation_mode
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
api_version do
|
33
|
+
raise ConfigurationError, "api_version has not been configured"
|
34
|
+
end
|
35
|
+
|
36
|
+
validate_if do |env, status, headers, body|
|
37
|
+
(200..299).cover?(status) && status != 204 # No Content
|
38
|
+
end
|
39
|
+
|
40
|
+
on_unavailable_request_version do |requested, available|
|
41
|
+
message = "The requested API version is invalid. " +
|
42
|
+
"Requested: #{requested}. " +
|
43
|
+
"Available: #{available}"
|
44
|
+
halt 406, JSON.dump(error: message)
|
45
|
+
end
|
46
|
+
|
47
|
+
self.endpoint_definition_files = []
|
48
|
+
yield self if block_given?
|
49
|
+
end
|
50
|
+
|
51
|
+
def endpoint_definition_files=(files)
|
52
|
+
self.endpoints = files.map do |file|
|
53
|
+
Endpoint.new(YAML.load_file file)
|
54
|
+
end
|
55
|
+
@endpoint_definition_files = files
|
56
|
+
end
|
57
|
+
|
58
|
+
def endpoints=(endpoints)
|
59
|
+
@endpoints = endpoints.extend(DefinitionFinder)
|
60
|
+
end
|
61
|
+
|
62
|
+
def api_version(version=nil, &block)
|
63
|
+
if [version, block].compact.size.even?
|
64
|
+
raise ConfigurationError.new("api_version requires a static version " +
|
65
|
+
"or a dynamic block, but not both")
|
66
|
+
end
|
67
|
+
|
68
|
+
@api_version_block = block || lambda { |*a| version }
|
69
|
+
end
|
70
|
+
|
71
|
+
def api_version_for(rack_env, endpoint=nil)
|
72
|
+
@api_version_block.call(rack_env, endpoint).to_s
|
73
|
+
end
|
74
|
+
|
75
|
+
def validate_if(&block)
|
76
|
+
@validate_if_block = block
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate?(*args)
|
80
|
+
@validate_if_block.call(*args)
|
81
|
+
end
|
82
|
+
|
83
|
+
def on_unavailable_request_version(&block)
|
84
|
+
@unavailable_request_version_block = block
|
85
|
+
end
|
86
|
+
|
87
|
+
def request_version_unavailable(execution_context, *args)
|
88
|
+
execution_context.instance_exec(*args, &@unavailable_request_version_block)
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.default
|
92
|
+
@default ||= Configuration.new
|
93
|
+
end
|
94
|
+
|
95
|
+
def customized_duplicate(&block)
|
96
|
+
block ||= lambda { |c| }
|
97
|
+
dup.tap(&block)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'json-schema'
|
2
|
+
require 'interpol/errors'
|
3
|
+
|
4
|
+
module Interpol
|
5
|
+
module HashFetcher
|
6
|
+
# Unfortunately, on JRuby 1.9, the error raised from Hash#fetch when
|
7
|
+
# the key is not found does not include the key itself :(. So we work
|
8
|
+
# around it here.
|
9
|
+
def fetch_from(hash, key)
|
10
|
+
hash.fetch(key) do
|
11
|
+
raise ArgumentError.new("key not found: #{key.inspect}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# Represents an endpoint. Instances of this class are constructed
|
17
|
+
# based on the endpoint definitions in the YAML files.
|
18
|
+
class Endpoint
|
19
|
+
include HashFetcher
|
20
|
+
attr_reader :name, :route, :method
|
21
|
+
|
22
|
+
def initialize(endpoint_hash)
|
23
|
+
@name = fetch_from(endpoint_hash, 'name')
|
24
|
+
@route = fetch_from(endpoint_hash, 'route')
|
25
|
+
@method = fetch_from(endpoint_hash, 'method').downcase.to_sym
|
26
|
+
@definitions = extract_definitions_from(endpoint_hash)
|
27
|
+
end
|
28
|
+
|
29
|
+
def find_example_for!(version)
|
30
|
+
d = @definitions.fetch(version) do
|
31
|
+
message = "No definition found for #{name} endpoint for version #{version}"
|
32
|
+
raise ArgumentError.new(message)
|
33
|
+
end
|
34
|
+
d.examples.first
|
35
|
+
end
|
36
|
+
|
37
|
+
def available_versions
|
38
|
+
definitions.map(&:version)
|
39
|
+
end
|
40
|
+
|
41
|
+
def definitions
|
42
|
+
@definitions.values.sort_by(&:version)
|
43
|
+
end
|
44
|
+
|
45
|
+
def route_matches?(path)
|
46
|
+
path =~ route_regex
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def route_regex
|
52
|
+
@route_regex ||= begin
|
53
|
+
regex_string = route.split('/').map do |path_part|
|
54
|
+
if path_part.start_with?(':')
|
55
|
+
'[^\/]+' # it's a parameter; match anything
|
56
|
+
else
|
57
|
+
Regexp.escape(path_part)
|
58
|
+
end
|
59
|
+
end.join('\/')
|
60
|
+
|
61
|
+
/\A#{regex_string}\z/
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def extract_definitions_from(endpoint_hash)
|
66
|
+
definitions = {}
|
67
|
+
|
68
|
+
fetch_from(endpoint_hash, 'definitions').each do |definition|
|
69
|
+
fetch_from(definition, 'versions').each do |version|
|
70
|
+
definitions[version] = EndpointDefinition.new(self, version, definition)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
definitions
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Wraps a single versioned definition for an endpoint.
|
79
|
+
# Provides the means to validate data against that version of the schema.
|
80
|
+
class EndpointDefinition
|
81
|
+
include HashFetcher
|
82
|
+
attr_reader :endpoint, :version, :schema, :examples
|
83
|
+
|
84
|
+
def initialize(endpoint, version, definition)
|
85
|
+
@endpoint = endpoint
|
86
|
+
@version = version
|
87
|
+
@schema = fetch_from(definition, 'schema')
|
88
|
+
@examples = fetch_from(definition, 'examples').map { |e| EndpointExample.new(e, self) }
|
89
|
+
make_schema_strict!(@schema)
|
90
|
+
end
|
91
|
+
|
92
|
+
def validate_data!(data)
|
93
|
+
errors = ::JSON::Validator.fully_validate_schema(schema)
|
94
|
+
raise ValidationError.new(errors, nil, description) if errors.any?
|
95
|
+
errors = ::JSON::Validator.fully_validate(schema, data)
|
96
|
+
raise ValidationError.new(errors, data, description) if errors.any?
|
97
|
+
end
|
98
|
+
|
99
|
+
def description
|
100
|
+
"#{endpoint.name} (v. #{version})"
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def make_schema_strict!(raw_schema, modify_object=true)
|
106
|
+
return unless Hash === raw_schema
|
107
|
+
|
108
|
+
raw_schema.each do |key, value|
|
109
|
+
make_schema_strict!(value, key != 'properties')
|
110
|
+
end
|
111
|
+
|
112
|
+
return unless modify_object
|
113
|
+
|
114
|
+
raw_schema['additionalProperties'] = false
|
115
|
+
raw_schema['required'] = !raw_schema.delete('optional')
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Wraps an example for a particular endpoint entry.
|
120
|
+
class EndpointExample
|
121
|
+
attr_reader :data, :definition
|
122
|
+
|
123
|
+
def initialize(data, definition)
|
124
|
+
@data, @definition = data, definition
|
125
|
+
end
|
126
|
+
|
127
|
+
def validate!
|
128
|
+
definition.validate_data!(data)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Interpol
|
2
|
+
# Base class that interpol errors should subclass.
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
# Error raised when the configuration is invalid.
|
6
|
+
class ConfigurationError < Error; end
|
7
|
+
|
8
|
+
# Error raised when data fails to validate against the schema
|
9
|
+
# for an endpoint.
|
10
|
+
class ValidationError < Error
|
11
|
+
attr_reader :errors
|
12
|
+
|
13
|
+
def initialize(errors = [], data = nil, endpoint_description = '')
|
14
|
+
@errors = errors
|
15
|
+
error_bullet_points = errors.map { |e| "\n - #{e}" }.join
|
16
|
+
message = "Found #{errors.size} error(s) when validating " +
|
17
|
+
"against endpoint #{endpoint_description}. " +
|
18
|
+
"Errors: #{error_bullet_points}.\n\nData:\n#{data.inspect}"
|
19
|
+
|
20
|
+
super(message)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Error raised when the schema validator cannot find a matching
|
25
|
+
# endpoint definition for the request.
|
26
|
+
class NoEndpointDefinitionFoundError < Error; end
|
27
|
+
end
|
28
|
+
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'interpol'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Interpol
|
5
|
+
# Rack middleware that validates response data against the schema
|
6
|
+
# definition for the endpoint. Can be configured to raise an
|
7
|
+
# error or warn in this condition. Intended for development and
|
8
|
+
# test use.
|
9
|
+
class ResponseSchemaValidator
|
10
|
+
def initialize(app, &block)
|
11
|
+
@config = Configuration.default.customized_duplicate(&block)
|
12
|
+
@app = app
|
13
|
+
@handler_class = @config.validation_mode == :warn ? HandlerWithWarnings : Handler
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(env)
|
17
|
+
status, headers, body = @app.call(env)
|
18
|
+
return status, headers, body unless @config.validate?(env, status, headers, body)
|
19
|
+
|
20
|
+
handler = @handler_class.new(status, headers, body, env, @config)
|
21
|
+
handler.validate!
|
22
|
+
|
23
|
+
return status, headers, handler.extracted_body
|
24
|
+
end
|
25
|
+
|
26
|
+
# Private: handles a responses and validates it. Validation
|
27
|
+
# errors will result in an error.
|
28
|
+
class Handler
|
29
|
+
attr_reader :status, :headers, :body, :env, :config
|
30
|
+
|
31
|
+
def initialize(status, headers, body, env, config)
|
32
|
+
@status, @headers, @body, @env, @config = status, headers, body, env, config
|
33
|
+
end
|
34
|
+
|
35
|
+
def validate!
|
36
|
+
unless validator == Interpol::DefinitionFinder::NoDefinitionFound
|
37
|
+
return validator.validate_data!(data)
|
38
|
+
end
|
39
|
+
|
40
|
+
raise NoEndpointDefinitionFoundError,
|
41
|
+
"No endpoint definition could be found for: #{request_method} '#{path}'"
|
42
|
+
end
|
43
|
+
|
44
|
+
# The only interface we can count on from the body is that it
|
45
|
+
# implements #each. It may not be re-windable. To preserve this
|
46
|
+
# interface while reading the whole thing, we need to extract
|
47
|
+
# it into our own array.
|
48
|
+
def extracted_body
|
49
|
+
@extracted_body ||= [].tap do |extracted_body|
|
50
|
+
body.each { |str| extracted_body << str }
|
51
|
+
body.close if body.respond_to?(:close)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def request_method
|
58
|
+
env.fetch('REQUEST_METHOD')
|
59
|
+
end
|
60
|
+
|
61
|
+
def data
|
62
|
+
@data ||= JSON.parse(extracted_body.join)
|
63
|
+
end
|
64
|
+
|
65
|
+
def path
|
66
|
+
env.fetch('PATH_INFO')
|
67
|
+
end
|
68
|
+
|
69
|
+
def validator
|
70
|
+
@validator ||= @config.endpoints.find_definition(request_method, path) do |endpoint|
|
71
|
+
@config.api_version_for(env, endpoint)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Private: Subclasses Handler in order to convert validation errors
|
77
|
+
# to warnings instead.
|
78
|
+
class HandlerWithWarnings < Handler
|
79
|
+
def validate!
|
80
|
+
super
|
81
|
+
rescue ValidationError, NoEndpointDefinitionFoundError => e
|
82
|
+
Kernel.warn e.message
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'interpol'
|
2
|
+
require 'sinatra/base'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Interpol
|
6
|
+
module StubApp
|
7
|
+
extend self
|
8
|
+
|
9
|
+
def build(&block)
|
10
|
+
config = Configuration.default.customized_duplicate(&block)
|
11
|
+
builder = Builder.new(config)
|
12
|
+
builder.build
|
13
|
+
builder.app
|
14
|
+
end
|
15
|
+
|
16
|
+
module Helpers
|
17
|
+
def interpol_config
|
18
|
+
self.class.interpol_config
|
19
|
+
end
|
20
|
+
|
21
|
+
def example_for(endpoint, version)
|
22
|
+
endpoint.find_example_for!(version)
|
23
|
+
rescue ArgumentError
|
24
|
+
interpol_config.request_version_unavailable(self, version, endpoint.available_versions)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Private: Builds a stub sinatra app for the given interpol
|
29
|
+
# configuration.
|
30
|
+
class Builder
|
31
|
+
attr_reader :app
|
32
|
+
|
33
|
+
def initialize(config)
|
34
|
+
@app = Sinatra.new do
|
35
|
+
set :interpol_config, config
|
36
|
+
helpers Helpers
|
37
|
+
not_found { JSON.dump(error: "The requested resource could not be found") }
|
38
|
+
before { content_type "application/json;charset=utf-8" }
|
39
|
+
get('/__ping') { JSON.dump(message: "Interpol stub app running.") }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def build
|
44
|
+
@app.interpol_config.endpoints.each do |endpoint|
|
45
|
+
app.send(endpoint.method, endpoint.route, &endpoint_definition(endpoint))
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def endpoint_definition(endpoint)
|
50
|
+
lambda do
|
51
|
+
version = interpol_config.api_version_for(request.env, endpoint)
|
52
|
+
example = example_for(endpoint, version)
|
53
|
+
example.validate!
|
54
|
+
JSON.dump(example.data)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'interpol'
|
2
|
+
|
3
|
+
module Interpol
|
4
|
+
module TestHelper
|
5
|
+
module Common
|
6
|
+
def each_example_from(endpoints)
|
7
|
+
endpoints.each do |endpoint|
|
8
|
+
endpoint.definitions.each do |definition|
|
9
|
+
definition.examples.each_with_index do |example, index|
|
10
|
+
yield endpoint, definition, example, index
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def define_interpol_example_tests(&block)
|
17
|
+
config = Configuration.default.customized_duplicate(&block)
|
18
|
+
|
19
|
+
each_example_from(config.endpoints) do |endpoint, definition, example, example_index|
|
20
|
+
description = "#{endpoint.name} (v #{definition.version}) has " +
|
21
|
+
"valid data for example #{example_index + 1}"
|
22
|
+
define_test(description) { example.validate! }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
module RSpec
|
28
|
+
include Common
|
29
|
+
|
30
|
+
def define_test(name, &block)
|
31
|
+
it(name, &block)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
module TestUnit
|
36
|
+
include Common
|
37
|
+
|
38
|
+
def define_test(name, &block)
|
39
|
+
define_method("test_#{name.gsub(/\W+/, '_')}", &block)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
data/lib/interpol.rb
ADDED
metadata
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: interpol
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Myron Marston
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-04-04 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: sinatra
|
16
|
+
requirement: &2152119280 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 1.3.2
|
22
|
+
- - <
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: 2.0.0
|
25
|
+
type: :runtime
|
26
|
+
prerelease: false
|
27
|
+
version_requirements: *2152119280
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: json-schema
|
30
|
+
requirement: &2152116920 !ruby/object:Gem::Requirement
|
31
|
+
none: false
|
32
|
+
requirements:
|
33
|
+
- - ~>
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: 1.0.5
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: *2152116920
|
39
|
+
- !ruby/object:Gem::Dependency
|
40
|
+
name: rspec
|
41
|
+
requirement: &2152114860 !ruby/object:Gem::Requirement
|
42
|
+
none: false
|
43
|
+
requirements:
|
44
|
+
- - ~>
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '2.9'
|
47
|
+
type: :development
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: *2152114860
|
50
|
+
- !ruby/object:Gem::Dependency
|
51
|
+
name: rspec-fire
|
52
|
+
requirement: &2152112940 !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ~>
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0.4'
|
58
|
+
type: :development
|
59
|
+
prerelease: false
|
60
|
+
version_requirements: *2152112940
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: simplecov
|
63
|
+
requirement: &2152112120 !ruby/object:Gem::Requirement
|
64
|
+
none: false
|
65
|
+
requirements:
|
66
|
+
- - ~>
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.6'
|
69
|
+
type: :development
|
70
|
+
prerelease: false
|
71
|
+
version_requirements: *2152112120
|
72
|
+
- !ruby/object:Gem::Dependency
|
73
|
+
name: cane
|
74
|
+
requirement: &2152111100 !ruby/object:Gem::Requirement
|
75
|
+
none: false
|
76
|
+
requirements:
|
77
|
+
- - ~>
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '1.2'
|
80
|
+
type: :development
|
81
|
+
prerelease: false
|
82
|
+
version_requirements: *2152111100
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: &2151941140 !ruby/object:Gem::Requirement
|
86
|
+
none: false
|
87
|
+
requirements:
|
88
|
+
- - ~>
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: 0.9.2.2
|
91
|
+
type: :development
|
92
|
+
prerelease: false
|
93
|
+
version_requirements: *2151941140
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: rack-test
|
96
|
+
requirement: &2151939360 !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - =
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: 0.6.1
|
102
|
+
type: :development
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: *2151939360
|
105
|
+
description: Interpol is a toolkit for working with API endpoint definition files,
|
106
|
+
giving you a stub app, a schema validation middleware, and browsable documentation.
|
107
|
+
email:
|
108
|
+
- myron.marston@gmail.com
|
109
|
+
executables: []
|
110
|
+
extensions: []
|
111
|
+
extra_rdoc_files: []
|
112
|
+
files:
|
113
|
+
- README.md
|
114
|
+
- License
|
115
|
+
- Gemfile
|
116
|
+
- Rakefile
|
117
|
+
- lib/interpol/configuration.rb
|
118
|
+
- lib/interpol/endpoint.rb
|
119
|
+
- lib/interpol/errors.rb
|
120
|
+
- lib/interpol/response_schema_validator.rb
|
121
|
+
- lib/interpol/stub_app.rb
|
122
|
+
- lib/interpol/test_helper.rb
|
123
|
+
- lib/interpol/version.rb
|
124
|
+
- lib/interpol.rb
|
125
|
+
homepage: ''
|
126
|
+
licenses: []
|
127
|
+
post_install_message:
|
128
|
+
rdoc_options: []
|
129
|
+
require_paths:
|
130
|
+
- lib
|
131
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
132
|
+
none: false
|
133
|
+
requirements:
|
134
|
+
- - ! '>='
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
138
|
+
none: false
|
139
|
+
requirements:
|
140
|
+
- - ! '>='
|
141
|
+
- !ruby/object:Gem::Version
|
142
|
+
version: '0'
|
143
|
+
requirements: []
|
144
|
+
rubyforge_project:
|
145
|
+
rubygems_version: 1.8.15
|
146
|
+
signing_key:
|
147
|
+
specification_version: 3
|
148
|
+
summary: Interpol is a toolkit for policing your HTTP JSON interface.
|
149
|
+
test_files: []
|