openapi_first 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 55b0bba2e51dd0517e3306e34d345ec34d2165a2a3d8536c140941b74818d945
4
+ data.tar.gz: 8ba6d8d04a3abb38e60a2716f7515df945b870e0b8a874b6d217773894e4642c
5
+ SHA512:
6
+ metadata.gz: d072794a009783f7cb4ea4141ffadfaea699701954f1c994dc6db69faf18e36ed554bdeb979d81d5b31d7abb8a668e17113156e96ccf311b99a0187a7d48dc06
7
+ data.tar.gz: 608a19940d52d6e554c6d53a95ea1942731d2a8df9118a3c91202d7b8220c7eb0899bffccbcd9a3ff6213ad520851ab446909bd6e2b4d17950937017179f264b
@@ -0,0 +1 @@
1
+ * @ahx
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+ Documentation:
4
+ Enabled: false
5
+ BlockLength:
6
+ Exclude:
7
+ - 'spec/**/*.rb'
8
+ - '*.gemspec'
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.1
7
+ before_install: gem install bundler -v 2.0.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Unreleased
2
+
3
+ # 0.6.0
4
+
5
+ - Set the content-type based on the OpenAPI description [#29](https://github.com/ahx/openapi-first/pull/29)
6
+ - Add CHANGELOG 📝
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in openapi_first.gemspec
6
+ gemspec
7
+
8
+ group :test, :development do
9
+ gem 'pry'
10
+ gem 'rubocop'
11
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,108 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ openapi_first (0.6.0)
5
+ json_schemer (~> 0.2)
6
+ multi_json (~> 1.13)
7
+ oas_parser (~> 0.19)
8
+ rack (~> 2)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ activesupport (5.2.3)
14
+ concurrent-ruby (~> 1.0, >= 1.0.2)
15
+ i18n (>= 0.7, < 2)
16
+ minitest (~> 5.1)
17
+ tzinfo (~> 1.1)
18
+ addressable (2.6.0)
19
+ public_suffix (>= 2.0.2, < 4.0)
20
+ ast (2.4.0)
21
+ builder (3.2.3)
22
+ coderay (1.1.2)
23
+ concurrent-ruby (1.1.5)
24
+ deep_merge (1.2.1)
25
+ diff-lcs (1.3)
26
+ ecma-re-validator (0.2.0)
27
+ regexp_parser (~> 1.2)
28
+ hana (1.3.5)
29
+ hansi (0.2.0)
30
+ i18n (1.6.0)
31
+ concurrent-ruby (~> 1.0)
32
+ jaro_winkler (1.5.2)
33
+ json_schemer (0.2.0)
34
+ ecma-re-validator (~> 0.2.0)
35
+ hana (~> 1.3.3)
36
+ regexp_parser (~> 1.2.0)
37
+ uri_template (~> 0.7.0)
38
+ method_source (0.9.2)
39
+ mini_portile2 (2.4.0)
40
+ minitest (5.11.3)
41
+ multi_json (1.13.1)
42
+ mustermann (1.0.3)
43
+ mustermann-contrib (1.0.3)
44
+ hansi (~> 0.2.0)
45
+ mustermann (= 1.0.3)
46
+ nokogiri (1.10.3)
47
+ mini_portile2 (~> 2.4.0)
48
+ oas_parser (0.19.0)
49
+ activesupport (>= 4.0.0)
50
+ addressable (~> 2.3)
51
+ builder (~> 3.2.3)
52
+ deep_merge (~> 1.2.1)
53
+ mustermann-contrib (~> 1.0.3s)
54
+ nokogiri
55
+ parallel (1.17.0)
56
+ parser (2.6.3.0)
57
+ ast (~> 2.4.0)
58
+ pry (0.12.2)
59
+ coderay (~> 1.1.0)
60
+ method_source (~> 0.9.0)
61
+ public_suffix (3.1.1)
62
+ rack (2.0.7)
63
+ rack-test (1.1.0)
64
+ rack (>= 1.0, < 3)
65
+ rainbow (3.0.0)
66
+ rake (10.5.0)
67
+ regexp_parser (1.2.0)
68
+ rspec (3.8.0)
69
+ rspec-core (~> 3.8.0)
70
+ rspec-expectations (~> 3.8.0)
71
+ rspec-mocks (~> 3.8.0)
72
+ rspec-core (3.8.0)
73
+ rspec-support (~> 3.8.0)
74
+ rspec-expectations (3.8.3)
75
+ diff-lcs (>= 1.2.0, < 2.0)
76
+ rspec-support (~> 3.8.0)
77
+ rspec-mocks (3.8.0)
78
+ diff-lcs (>= 1.2.0, < 2.0)
79
+ rspec-support (~> 3.8.0)
80
+ rspec-support (3.8.0)
81
+ rubocop (0.69.0)
82
+ jaro_winkler (~> 1.5.1)
83
+ parallel (~> 1.10)
84
+ parser (>= 2.6)
85
+ rainbow (>= 2.2.2, < 4.0)
86
+ ruby-progressbar (~> 1.7)
87
+ unicode-display_width (>= 1.4.0, < 1.7)
88
+ ruby-progressbar (1.10.0)
89
+ thread_safe (0.3.6)
90
+ tzinfo (1.2.5)
91
+ thread_safe (~> 0.1)
92
+ unicode-display_width (1.6.0)
93
+ uri_template (0.7.0)
94
+
95
+ PLATFORMS
96
+ ruby
97
+
98
+ DEPENDENCIES
99
+ bundler (~> 2.0)
100
+ openapi_first!
101
+ pry
102
+ rack-test (~> 1)
103
+ rake (~> 10.0)
104
+ rspec (~> 3.0)
105
+ rubocop
106
+
107
+ BUNDLED WITH
108
+ 2.0.1
data/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # OpenapiFirst
2
+
3
+ OpenapiFirst helps to implement Rack based HTTP APIs based on an [OpenApi](https://www.openapis.org/) API description. The idea is that you create an API description first, then add minimal code about your business logic (some call this "Resolver") and be done.
4
+
5
+ ## TL;DR
6
+
7
+ ```ruby
8
+ module Pets
9
+ def self.find_pet(params, _res) # "find_pet" is an operationId from your OpenApi file
10
+ {
11
+ id: params['id'],
12
+ name: 'Oscar'
13
+ }
14
+ end
15
+ end
16
+
17
+ # In config.ru:
18
+ require 'openapi_first'
19
+ run OpenapiFirst.app('./openapi/openapi.yaml', namespace: Pets)
20
+ ```
21
+
22
+ The above will:
23
+
24
+ - Validate the request and respond with 400 if the request does not match against your spec
25
+ - Map the request (for example `GET /pet/1`) to the method call `Pets.find_pet`
26
+ - Set the response content type according to your spec (here with the default status code `200`)
27
+
28
+ ### Usage as Rack middlware
29
+
30
+ ```ruby
31
+ # Just like the above, except the last line
32
+ # ...
33
+ run OpenapiFirst.middleware('./openapi/openapi.yaml', namespace: Pets)
34
+ ```
35
+
36
+ When using the middleware, all requests that are not part of the API description will be passed to the next app.
37
+
38
+ ## Try it out
39
+
40
+ See [example](examples)
41
+
42
+ ## Missing features
43
+ See [issues](https://github.com/ahx/openapi_first/issues).
44
+
45
+ ## Start
46
+
47
+ Start with writing an OpenAPI file that describes the API, which you are about to write. Use a [validator](http://speccy.io/) to make sure the file is valid.
48
+
49
+ We recommend saving the file as `openapi/openapi.yaml`.
50
+
51
+ ## Installation
52
+
53
+ Add this line to your application's Gemfile:
54
+
55
+ ```ruby
56
+ gem 'openapi_first'
57
+ ```
58
+
59
+ OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
60
+
61
+ ## How it works
62
+
63
+ OpenapiFirst offers Rack middlewares to auto-implement different aspects of request validation:
64
+
65
+ - Query parameter validation
66
+ - Request body validation
67
+ - Mapping request to a function call
68
+
69
+ It starts with a router middleware:
70
+
71
+ ```ruby
72
+ spec = OpenapiFirst.load('petstore.yaml')
73
+ use OpenapiFirst::Router, spec: spec
74
+ ```
75
+
76
+ If the request is not valid, these middlewares return a 400 status code with a body that describes the error. If unkwon routes in your application exist, which are not specified in the openapi spec file, set `:allow_unknown_operation` to `true`.
77
+
78
+ The error responses conform with [JSON:API](https://jsonapi.org).
79
+
80
+ Here's an example response body for a missing query parameter "search":
81
+
82
+ ```json
83
+ http-status: 400
84
+ content-type: "application/vnd.api+json"
85
+
86
+ {
87
+ "errors": [
88
+ {
89
+ "title": "is missing",
90
+ "source": {
91
+ "parameter": "search"
92
+ }
93
+ }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ ### Query parameter validation
99
+
100
+ ```ruby
101
+ use OpenapiFirst::QueryParameterValidation
102
+ ```
103
+
104
+ By default OpenapiFirst does not allow additional query parameters and will respond with 400 if additional parameters are sent. You can allow additional parameters with `additional_properties: true`:
105
+
106
+ ```ruby
107
+ use OpenapiFirst::QueryParameterValidation,
108
+ allow_additional_parameters: true
109
+ ```
110
+
111
+ The middleware filteres all top-level query parameters and adds these to the Rack env: `env[OpenapiFirst::QUERY_PARAMS]`.
112
+ If you want to forbid nested query parameters you will need to use `additionalProperties: false` in your query parameter json schema.
113
+
114
+ OpenapiFirst does not support parameters set to `explode: false` and treats nested query parameters (`filter[foo]=bar`) like [`style: deepObject`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#style-values).
115
+
116
+ ### TODO: Header, Cookie, Path parameter validation
117
+
118
+ tbd.
119
+
120
+ ### Request Body validation
121
+
122
+ ```ruby
123
+ # Add the middleware:
124
+ use OpenapiFirst::RequestBodyValidation
125
+ ```
126
+
127
+ This will return a `415` if the requests content type does not match or `400` if the request body is invalid.
128
+ This will add the parsed request body to `env[OpenapiFirst::REQUEST_BODY]`.
129
+
130
+ OpenAPI request (and response) body validation is based on [JSON Schema](http://json-schema.org/).
131
+
132
+ ### Mapping request to a function call
133
+
134
+ OpenapiFirst has a `OperationResolver` middleware to map the HTTP request to a function (method) call
135
+
136
+ ```ruby
137
+ # Define some methods
138
+ module MyApi
139
+ def create_pet(params, res)
140
+ res.status = 201
141
+ {
142
+ id: '1',
143
+ name: params['name']
144
+ }
145
+ end
146
+ end
147
+
148
+ # Add the middleware:
149
+ use OpenapiFirst::OperationResolver, namespace: MyApi
150
+ # If the operation was not found in the OAS file, the next app will be called
151
+
152
+ # OR use it as a Rack app via `run`:
153
+ run OpenapiFirst::OperationResolver, namespace: Pets
154
+ # If the operation was not found, this will return 404
155
+
156
+ # Now make a request like
157
+ # POST /pets, { name: 'Oscar' }
158
+ ```
159
+
160
+ The resolver function is found via the [`operationId`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operation-object) attribute in your API description. If your operationId has dots like `Pets.find`, the resolver above would call `MyApi::Pets.find(params, req)`.
161
+
162
+ These resolver functions are called with two arguments:
163
+
164
+ - `params` - Holds the parsed request body, filtered query params and path parameters
165
+ - `res` - Holds a Rack::Response that you can modify if needed
166
+
167
+ You can call `params.env` to access the Rack env (just like in [Hanami actions](https://guides.hanamirb.org/actions/parameters/))
168
+
169
+ There are two ways to set the response body:
170
+
171
+ - Calling `res.write "things"` (see [Rack::Response](https://www.rubydoc.info/github/rack/rack/Rack/Response))
172
+ - Returning a value from the function (see example above) (this will always converted to JSON)
173
+
174
+ ## Testing
175
+
176
+ OpenapiFirst offers tools to help testing your app.
177
+
178
+ ### Response validation
179
+
180
+ Response validation is to make sure your app responds as described in your OpenAPI spec. You usually do this in your tests using [rack-test](https://github.com/rack-test/rack-test).
181
+
182
+ ```ruby
183
+ # In your test:
184
+ require 'openapi_first/response_validator'
185
+ spec = OpenapiFirst.load('petstore.yaml')
186
+ validator = OpenapiFirst::ResponseValidator.new(spec)
187
+ validator.validate(last_request, last_response).errors? # => true or false
188
+ ```
189
+
190
+ TODO: Add RSpec matcher (via extra rubygem)
191
+
192
+ ### Coverage
193
+
194
+ (This is a bit experimental. Please try it out and give feedback.)
195
+
196
+ `OpenapiFirst::Coverage` helps you make sure, that you have called all endpoints of your OAS file when running tests via `rack-test`.
197
+
198
+ ```ruby
199
+ # In your test (rspec example):
200
+ require 'openapi_first/coverage'
201
+
202
+ describe MyApp do
203
+ include Rack::Test::Methods
204
+
205
+ before(:all) do
206
+ spec = OpenapiFirst.load('petstore.yaml')
207
+ @app_wrapper = OpenapiFirst::Coverage.new(MyApp, spec)
208
+ end
209
+
210
+ after(:all) do
211
+ message = "The following paths have not been called yet: #{@app_wrapper.to_be_called}"
212
+ expect(@app_wrapper.to_be_called).to be_empty
213
+ end
214
+
215
+ # Overwrite `#app` to make rack-test call the wrapped app
216
+ def app
217
+ @app_wrapper
218
+ end
219
+
220
+ it 'does things' do
221
+ get '/i/my/stuff'
222
+ # …
223
+ end
224
+ end
225
+ ```
226
+
227
+ ## Mocking
228
+
229
+ Currently out of scope. Use https://github.com/JustinFeng/fakeit or something else.
230
+
231
+ ## Alternatives
232
+
233
+ This gem is inspired by [committee](https://github.com/interagent/committee), which has much more features like response stubs or support for Hyper-Schema or OpenAPI 2.
234
+
235
+ ## Development
236
+
237
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
238
+
239
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
240
+
241
+ ## Contributing
242
+
243
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ahx/openapi_first.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RuboCop::RakeTask.new
8
+
9
+ task :version do
10
+ puts Gem::Specification.load('openapi_first.gemspec').version
11
+ end
12
+
13
+ RSpec::Core::RakeTask.new(:spec)
14
+
15
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'openapi_first'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,13 @@
1
+ # Example
2
+
3
+ How to run the example:
4
+
5
+ ```bash
6
+ cd examples
7
+ bundle install
8
+ bundle exec rackup
9
+ ```
10
+
11
+ open http://localhost:9292/
12
+
13
+ 🎉
data/examples/app.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_first'
4
+
5
+ module Example
6
+ def self.find_thing(_params, _res)
7
+ { hello: 'world' }
8
+ end
9
+ end
10
+
11
+ oas_path = File.absolute_path('./openapi.yaml', __dir__)
12
+ App = OpenapiFirst.app(oas_path, namespace: Example)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('../lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+
6
+ require_relative 'app'
7
+ run App
@@ -0,0 +1,29 @@
1
+ openapi: 3.0.0
2
+ info:
3
+ title: "API"
4
+ version: "1.0.0"
5
+ contact:
6
+ name: Contact Name
7
+ email: contact@example.com
8
+ url: https://example.com/
9
+ tags:
10
+ - name: Metadata
11
+ description: Metadata related requests
12
+ paths:
13
+ /:
14
+ get:
15
+ operationId: find_thing
16
+ summary: Get metadata from the root of the API
17
+ tags: ["Metadata"]
18
+ responses:
19
+ "200":
20
+ description: OK
21
+ content:
22
+ application/json:
23
+ schema:
24
+ type: object
25
+ required: [hello]
26
+ properties:
27
+ hello:
28
+ type: string
29
+
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oas_parser'
4
+ require 'openapi_first/version'
5
+ require 'openapi_first/router'
6
+ require 'openapi_first/query_parameter_validation'
7
+ require 'openapi_first/request_body_validation'
8
+ require 'openapi_first/operation_resolver'
9
+ require 'openapi_first/app'
10
+
11
+ module OpenapiFirst
12
+ OPERATION = 'openapi_first.operation'
13
+ PATH_PARAMS = 'openapi_first.path_params'
14
+ REQUEST_BODY = 'openapi_first.parsed_request_body'
15
+ QUERY_PARAMS = 'openapi_first.query_params'
16
+
17
+ def self.load(spec_path)
18
+ OasParser::Definition.resolve(spec_path)
19
+ end
20
+
21
+ def self.app(spec, namespace:)
22
+ spec = OpenapiFirst.load(spec) if spec.is_a?(String)
23
+ App.new(spec, namespace: namespace)
24
+ end
25
+
26
+ def self.middleware(spec, namespace:)
27
+ spec = OpenapiFirst.load(spec) if spec.is_a?(String)
28
+ AppWithOptions.new(spec, namespace: namespace)
29
+ end
30
+
31
+ class AppWithOptions
32
+ def initialize(*options)
33
+ @options = options
34
+ end
35
+
36
+ def new(app)
37
+ App.new(app, *@options)
38
+ end
39
+ end
40
+
41
+ class Error < StandardError; end
42
+ # Your code goes here...
43
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+
5
+ module OpenapiFirst
6
+ class App
7
+ def initialize(
8
+ app = nil, # rubocop:disable Style/OptionalArguments
9
+ spec,
10
+ namespace:,
11
+ allow_unknown_operation: !app.nil?
12
+ )
13
+ @stack = Rack::Builder.new do
14
+ use OpenapiFirst::Router,
15
+ spec: spec,
16
+ allow_unknown_operation: allow_unknown_operation
17
+ use OpenapiFirst::QueryParameterValidation
18
+ use OpenapiFirst::RequestBodyValidation
19
+ run OpenapiFirst::OperationResolver.new(app, namespace: namespace)
20
+ end
21
+ end
22
+
23
+ def call(env)
24
+ @stack.call(env)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Coverage
5
+ attr_reader :to_be_called
6
+
7
+ def initialize(app, spec)
8
+ @app = app
9
+ @spec = spec
10
+ @to_be_called = spec.endpoints.map do |endpoint|
11
+ endpoint_id(endpoint)
12
+ end
13
+ end
14
+
15
+ def call(env)
16
+ endpoint = endpoint_for_request(Rack::Request.new(env))
17
+ @to_be_called.delete(endpoint_id(endpoint)) if endpoint
18
+ @app.call(env)
19
+ end
20
+
21
+ private
22
+
23
+ def endpoint_id(endpoint)
24
+ "#{endpoint.path.path}##{endpoint.method}"
25
+ end
26
+
27
+ def endpoint_for_request(request)
28
+ @spec
29
+ .path_by_path(request.path)
30
+ .endpoint_by_method(request.request_method.downcase)
31
+ rescue OasParser::PathNotFound
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponseMethod
5
+ def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
6
+ {
7
+ status: status.to_s,
8
+ title: title
9
+ }
10
+ end
11
+
12
+ def error_response(status, errors = [default_error(status)])
13
+ Rack::Response.new(
14
+ MultiJson.dump(errors: errors),
15
+ status,
16
+ Rack::CONTENT_TYPE => 'application/vnd.api+json'
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+
5
+ module OpenapiFirst
6
+ class OperationResolver
7
+ DEFAULT_APP = ->(_env) { Rack::Response.new('', 404) }
8
+
9
+ def initialize(app = DEFAULT_APP, namespace:)
10
+ @app = app
11
+ @namespace = namespace
12
+ end
13
+
14
+ def call(env)
15
+ operation = env[OpenapiFirst::OPERATION]
16
+ return @app.call(env) unless operation
17
+
18
+ operation_id = operation.operation_id
19
+ res = Rack::Response.new
20
+ result = call_operation_method(operation_id, env, res)
21
+ res.write MultiJson.dump(result) if result && res.body.empty?
22
+ res[Rack::CONTENT_TYPE] ||= find_content_type(operation, res.status)
23
+ res
24
+ end
25
+
26
+ private
27
+
28
+ def find_content_type(operation, status)
29
+ content = operation
30
+ .response_by_code(status.to_s, use_default: true)
31
+ .content
32
+ content.keys[0] if content
33
+ end
34
+
35
+ def call_operation_method(operation_id, env, res)
36
+ target = @namespace
37
+ methods = operation_id.split('.')
38
+ final = methods.pop
39
+ methods.each { |m| target = target.send(m) }
40
+ params = build_params(env)
41
+ target.send(final, params, res)
42
+ end
43
+
44
+ def build_params(env)
45
+ sources = [
46
+ env[PATH_PARAMS],
47
+ env[QUERY_PARAMS],
48
+ env[REQUEST_BODY]
49
+ ].tap(&:compact!)
50
+ hash = {}.merge!(*sources)
51
+ hash.define_singleton_method(:env) { env }
52
+ hash
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'json_schemer'
5
+ require 'multi_json'
6
+ require_relative 'validation_format'
7
+ require_relative 'error_response_method'
8
+
9
+ module OpenapiFirst
10
+ class QueryParameterValidation
11
+ include ErrorResponseMethod
12
+
13
+ def initialize(app, allow_additional_parameters: false)
14
+ @app = app
15
+ @additional_properties = allow_additional_parameters
16
+ end
17
+
18
+ def call(env)
19
+ req = Rack::Request.new(env)
20
+ schema = parameter_schema(env[OpenapiFirst::OPERATION])
21
+ params = req.params
22
+ if schema
23
+ errors = schema && JSONSchemer.schema(schema).validate(params)
24
+ return error_response(400, serialize_errors(errors)) if errors&.any?
25
+
26
+ req.env[QUERY_PARAMS] = allowed_query_parameters(schema, params)
27
+ end
28
+
29
+ @app.call(env)
30
+ end
31
+
32
+ def allowed_query_parameters(params_schema, query_params)
33
+ params_schema['properties']
34
+ .keys
35
+ .each_with_object({}) do |parameter_name, filtered|
36
+ value = query_params[parameter_name]
37
+ filtered[parameter_name] = value if value
38
+ end
39
+ end
40
+
41
+ def parameter_schema(operation)
42
+ return unless operation&.query_parameters&.any?
43
+
44
+ operation.query_parameters.each_with_object(
45
+ 'type' => 'object',
46
+ 'required' => [],
47
+ 'additionalProperties' => @additional_properties,
48
+ 'properties' => {}
49
+ ) do |parameter, schema|
50
+ schema['required'] << parameter.name if parameter.required
51
+ schema['properties'][parameter.name] = parameter.schema
52
+ end
53
+ end
54
+
55
+ def serialize_errors(validation_errors)
56
+ validation_errors.map do |error|
57
+ {
58
+ source: {
59
+ parameter: File.basename(error['data_pointer'])
60
+ }
61
+ }.update(ValidationFormat.error_details(error))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'json_schemer'
5
+ require 'multi_json'
6
+ require_relative 'error_response_method'
7
+ require_relative 'validation_format'
8
+
9
+ module OpenapiFirst
10
+ class RequestBodyValidation
11
+ include ErrorResponseMethod
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env) # rubocop:disable Metrics/MethodLength
18
+ operation = env[OpenapiFirst::OPERATION]
19
+ return @app.call(env) unless operation&.request_body
20
+
21
+ req = Rack::Request.new(env)
22
+ content_type = req.content_type
23
+ body = req.body
24
+ catch(:halt) do
25
+ validate_request_content_type!(content_type, operation)
26
+ validate_request_body_presence!(env, body, operation)
27
+ parse_and_validate_request_body!(env, content_type, body, operation)
28
+ @app.call(env)
29
+ end
30
+ end
31
+
32
+ def halt(response)
33
+ throw :halt, response
34
+ end
35
+
36
+ def validate_request_content_type!(content_type, operation)
37
+ return if content_type_valid?(content_type, operation)
38
+
39
+ halt(error_response(415))
40
+ end
41
+
42
+ def validate_request_body_presence!(env, body, operation)
43
+ return unless body.size.zero?
44
+
45
+ if operation.request_body.required
46
+ halt(error_response(415, 'Request body is required'))
47
+ end
48
+ halt(@app.call(env))
49
+ end
50
+
51
+ def parse_and_validate_request_body!(env, content_type, body, operation)
52
+ schema = request_body_schema(content_type, operation)
53
+ return unless schema
54
+
55
+ parsed_request_body = MultiJson.load(body)
56
+ errors = validate_json_schema(schema, parsed_request_body)
57
+ halt(error_response(400, serialize_errors(errors))) if errors&.any?
58
+ env[OpenapiFirst::REQUEST_BODY] = parsed_request_body
59
+ end
60
+
61
+ def validate_json_schema(schema, object)
62
+ JSONSchemer.schema(schema).validate(object)
63
+ end
64
+
65
+ def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
66
+ {
67
+ status: status.to_s,
68
+ title: title
69
+ }
70
+ end
71
+
72
+ def content_type_valid?(content_type, endpoint)
73
+ endpoint.request_body.content[content_type]
74
+ end
75
+
76
+ def request_body_schema(content_type, endpoint)
77
+ return unless endpoint
78
+
79
+ endpoint.request_body.content[content_type]&.fetch('schema')
80
+ end
81
+
82
+ def serialize_errors(validation_errors)
83
+ validation_errors.map do |error|
84
+ {
85
+ source: {
86
+ pointer: error['data_pointer']
87
+ }
88
+ }.update(ValidationFormat.error_details(error))
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+ require 'multi_json'
5
+ require_relative 'validation'
6
+
7
+ module OpenapiFirst
8
+ class ResponseValidator
9
+ def initialize(schema)
10
+ @schema = schema
11
+ end
12
+
13
+ def validate(request, response)
14
+ errors = validation_errors(request, response)
15
+ Validation.new(errors || [])
16
+ rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
17
+ Validation.new([e.message])
18
+ end
19
+
20
+ private
21
+
22
+ def validation_errors(request, response)
23
+ content = response_for(request, response).content
24
+ return unless content
25
+
26
+ content_type = content[response.content_type]
27
+ unless content_type
28
+ return ["Content type not found: '#{response.content_type}'"]
29
+ end
30
+
31
+ response_schema = content_type['schema']
32
+ return unless response_schema
33
+
34
+ response_data = MultiJson.load(response.body)
35
+ validate_json_schema(response_schema, response_data)
36
+ end
37
+
38
+ def validate_json_schema(schema, data)
39
+ JSONSchemer.schema(schema).validate(data).to_a.map do |error|
40
+ error.delete('root_schema')
41
+ error
42
+ end
43
+ end
44
+
45
+ def response_for(request, response)
46
+ @schema
47
+ .path_by_path(request.path)
48
+ .endpoint_by_method(request.request_method.downcase)
49
+ .response_by_code(response.status.to_s, use_default: true)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'json_schemer'
5
+ require 'multi_json'
6
+ require 'mustermann/template'
7
+
8
+ module OpenapiFirst
9
+ class Router
10
+ def initialize(app, spec:, allow_unknown_operation: false)
11
+ @app = app
12
+ @spec = spec
13
+ @allow_unknown_operation = allow_unknown_operation
14
+ end
15
+
16
+ def call(env)
17
+ req = Rack::Request.new(env)
18
+ operation = env[OPERATION] = find_operation(req)
19
+ path_params = find_path_params(operation, req)
20
+ env[PATH_PARAMS] = path_params if path_params
21
+ return @app.call(env) if operation || @allow_unknown_operation
22
+
23
+ Rack::Response.new('', 404)
24
+ end
25
+
26
+ def find_operation(req)
27
+ path = @spec.path_by_path(req.path)
28
+ path.endpoint_by_method(req.request_method.downcase)
29
+ rescue OasParser::PathNotFound, OasParser::MethodNotFound
30
+ nil
31
+ end
32
+
33
+ def find_path_params(operation, req)
34
+ return unless operation&.path_parameters&.any?
35
+
36
+ pattern = Mustermann::Template.new(operation.path.path)
37
+ pattern.params(req.path)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Validation
5
+ attr_reader :errors
6
+
7
+ def initialize(errors)
8
+ @errors = errors
9
+ end
10
+
11
+ def errors?
12
+ !errors.empty?
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ValidationFormat
5
+ # rubocop:disable Metrics/MethodLength
6
+ def self.error_details(error)
7
+ if error['type'] == 'pattern'
8
+ {
9
+ title: 'is not valid',
10
+ detail: "does not match pattern '#{error['schema']['pattern']}'"
11
+ }
12
+ elsif error['type'] == 'required'
13
+ missing_keys = error['details']['missing_keys']
14
+ {
15
+ title: "is missing required properties: #{missing_keys.join(', ')}"
16
+ }
17
+ elsif error['schema'] == false
18
+ { title: 'unknown fields are not allowed' }
19
+ else
20
+ { title: 'is not valid' }
21
+ end
22
+ end
23
+ # rubocop:enable Metrics/MethodLength
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ VERSION = '0.6.0'
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'openapi_first/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'openapi_first'
9
+ spec.version = OpenapiFirst::VERSION
10
+ spec.authors = ['Andreas Haller']
11
+ spec.email = ['andreas.haller@posteo.de']
12
+ spec.licenses = ['MIT']
13
+
14
+ spec.summary = 'Implement REST APIs based on an OpenApi API description'
15
+ spec.homepage = 'https://github.com/ahx/openapi_first'
16
+
17
+ if spec.respond_to?(:metadata)
18
+ spec.metadata['https://github.com/ahx/openapi_first'] = spec.homepage
19
+ else
20
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
21
+ 'public gem pushes.'
22
+ end
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`
26
+ .split("\x0")
27
+ .reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ .reject { |f| %w[Dockerfile Jenkinsfile].include?(f) }
29
+ end
30
+ spec.bindir = 'exe'
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'json_schemer', '~> 0.2'
34
+ spec.add_dependency 'multi_json', '~> 1.13'
35
+ spec.add_dependency 'oas_parser', '~> 0.19'
36
+ spec.add_dependency 'rack', '~> 2'
37
+
38
+ spec.add_development_dependency 'bundler', '~> 2.0'
39
+ spec.add_development_dependency 'rack-test', '~> 1'
40
+ spec.add_development_dependency 'rake', '~> 10.0'
41
+ spec.add_development_dependency 'rspec', '~> 3.0'
42
+ end
metadata ADDED
@@ -0,0 +1,185 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openapi_first
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: ruby
6
+ authors:
7
+ - Andreas Haller
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-07-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json_schemer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: multi_json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.13'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: oas_parser
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.19'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.19'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rack-test
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ description:
126
+ email:
127
+ - andreas.haller@posteo.de
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".github/CODEOWNERS"
133
+ - ".gitignore"
134
+ - ".rspec"
135
+ - ".rubocop.yml"
136
+ - ".travis.yml"
137
+ - CHANGELOG.md
138
+ - Gemfile
139
+ - Gemfile.lock
140
+ - README.md
141
+ - Rakefile
142
+ - bin/console
143
+ - bin/setup
144
+ - examples/README.md
145
+ - examples/app.rb
146
+ - examples/config.ru
147
+ - examples/openapi.yaml
148
+ - lib/openapi_first.rb
149
+ - lib/openapi_first/app.rb
150
+ - lib/openapi_first/coverage.rb
151
+ - lib/openapi_first/error_response_method.rb
152
+ - lib/openapi_first/operation_resolver.rb
153
+ - lib/openapi_first/query_parameter_validation.rb
154
+ - lib/openapi_first/request_body_validation.rb
155
+ - lib/openapi_first/response_validator.rb
156
+ - lib/openapi_first/router.rb
157
+ - lib/openapi_first/validation.rb
158
+ - lib/openapi_first/validation_format.rb
159
+ - lib/openapi_first/version.rb
160
+ - openapi_first.gemspec
161
+ homepage: https://github.com/ahx/openapi_first
162
+ licenses:
163
+ - MIT
164
+ metadata:
165
+ https://github.com/ahx/openapi_first: https://github.com/ahx/openapi_first
166
+ post_install_message:
167
+ rdoc_options: []
168
+ require_paths:
169
+ - lib
170
+ required_ruby_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ required_rubygems_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ requirements: []
181
+ rubygems_version: 3.0.3
182
+ signing_key:
183
+ specification_version: 4
184
+ summary: Implement REST APIs based on an OpenApi API description
185
+ test_files: []