openapi_first 0.6.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.
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: []