openapi_first 0.6.10 → 0.7.0.alpha1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile.lock +20 -11
- data/README.md +25 -71
- data/benchmarks/Gemfile +1 -1
- data/benchmarks/Gemfile.lock +10 -5
- data/benchmarks/apps/openapi_first_resolve_only.ru +2 -2
- data/lib/openapi_first/app.rb +5 -5
- data/lib/openapi_first/coverage.rb +4 -3
- data/lib/openapi_first/definition.rb +3 -1
- data/lib/openapi_first/operation.rb +48 -0
- data/lib/openapi_first/operation_resolver.rb +10 -37
- data/lib/openapi_first/request_validation.rb +30 -21
- data/lib/openapi_first/router.rb +63 -14
- data/lib/openapi_first/utils.rb +31 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +2 -2
- data/openapi_first.gemspec +2 -1
- metadata +29 -15
- data/benchmarks/apps/openapi_first_request_validation_only.ru +0 -30
- data/lib/openapi_first/query_parameters.rb +0 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4885473ec6373b0fcfd9baace6175296f362099ba8ee73de0b8c1538b3420504
|
4
|
+
data.tar.gz: 5a72ae579e67bcb17c33debb3adbcae1cdaae09ad98ccc0e1158a59719d00115
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d2783c23798622b8bcc6c6f9520272d0477760d405f9879d4dc39e94b1d361b3873a87147ccef81e0ba3ec5c59e67941f645e41caf52dd221ddb915167140c6d
|
7
|
+
data.tar.gz: d540387f504454feca50ea746ef87745aed68e841bc5eb734c8d2e3419676e221a8dd93404601f010e3725152cd0e0e9a2775a1646690b8f4e5b62fbd8d7ca93
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,12 @@
|
|
2
2
|
|
3
3
|
## Unreleased
|
4
4
|
|
5
|
+
- Make use of hanami-router, because it's fast
|
6
|
+
- Remove OpenapiFirst::Coverage
|
7
|
+
- Remove option `allow_unknown_query_paramerters`
|
8
|
+
- Move the namespace option to Router
|
9
|
+
- Convert numeric parameters to `Integer` or `Float`
|
10
|
+
|
5
11
|
## 0.6.9
|
6
12
|
- Removed radix tree, because of a bug (https://github.com/namusyaka/r2ree-ruby/issues/2)
|
7
13
|
|
data/Gemfile.lock
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
openapi_first (0.
|
4
|
+
openapi_first (0.7.0.alpha1)
|
5
|
+
hanami-router (~> 2.0.alpha2)
|
6
|
+
hanami-utils (~> 2.0.alpha1)
|
5
7
|
json_schemer (~> 0.2)
|
6
8
|
multi_json (~> 1.14)
|
7
|
-
mustermann-contrib (~> 1.1.1)
|
8
9
|
oas_parser (~> 0.24)
|
9
10
|
rack (~> 2.2)
|
10
11
|
|
@@ -25,9 +26,16 @@ GEM
|
|
25
26
|
concurrent-ruby (1.1.6)
|
26
27
|
deep_merge (1.2.1)
|
27
28
|
diff-lcs (1.3)
|
28
|
-
ecma-re-validator (0.2.
|
29
|
+
ecma-re-validator (0.2.0)
|
29
30
|
regexp_parser (~> 1.2)
|
30
31
|
hana (1.3.5)
|
32
|
+
hanami-router (2.0.0.alpha2)
|
33
|
+
mustermann (~> 1.0)
|
34
|
+
mustermann-contrib (~> 1.0)
|
35
|
+
rack (~> 2.0)
|
36
|
+
hanami-utils (2.0.0.alpha1)
|
37
|
+
concurrent-ruby (~> 1.0)
|
38
|
+
transproc (~> 1.0)
|
31
39
|
hansi (0.2.0)
|
32
40
|
hash-deep-merge (0.1.1)
|
33
41
|
i18n (1.8.2)
|
@@ -38,7 +46,7 @@ GEM
|
|
38
46
|
hana (~> 1.3)
|
39
47
|
regexp_parser (~> 1.5)
|
40
48
|
uri_template (~> 0.7)
|
41
|
-
method_source (0.
|
49
|
+
method_source (1.0.0)
|
42
50
|
mini_portile2 (2.4.0)
|
43
51
|
minitest (5.14.0)
|
44
52
|
multi_json (1.14.1)
|
@@ -58,12 +66,12 @@ GEM
|
|
58
66
|
mustermann-contrib (~> 1.1.1)
|
59
67
|
nokogiri
|
60
68
|
parallel (1.19.1)
|
61
|
-
parser (2.7.0.
|
69
|
+
parser (2.7.0.5)
|
62
70
|
ast (~> 2.4.0)
|
63
|
-
pry (0.
|
64
|
-
coderay (~> 1.1
|
65
|
-
method_source (~>
|
66
|
-
public_suffix (4.0.
|
71
|
+
pry (0.13.0)
|
72
|
+
coderay (~> 1.1)
|
73
|
+
method_source (~> 1.0)
|
74
|
+
public_suffix (4.0.3)
|
67
75
|
rack (2.2.2)
|
68
76
|
rack-test (1.1.0)
|
69
77
|
rack (>= 1.0, < 3)
|
@@ -77,7 +85,7 @@ GEM
|
|
77
85
|
rspec-mocks (~> 3.9.0)
|
78
86
|
rspec-core (3.9.1)
|
79
87
|
rspec-support (~> 3.9.1)
|
80
|
-
rspec-expectations (3.9.
|
88
|
+
rspec-expectations (3.9.1)
|
81
89
|
diff-lcs (>= 1.2.0, < 2.0)
|
82
90
|
rspec-support (~> 3.9.0)
|
83
91
|
rspec-mocks (3.9.1)
|
@@ -95,7 +103,8 @@ GEM
|
|
95
103
|
ruby-progressbar (1.10.1)
|
96
104
|
ruby2_keywords (0.0.2)
|
97
105
|
thread_safe (0.3.6)
|
98
|
-
|
106
|
+
transproc (1.1.1)
|
107
|
+
tzinfo (1.2.6)
|
99
108
|
thread_safe (~> 0.1)
|
100
109
|
unicode-display_width (1.6.1)
|
101
110
|
uri_template (0.7.0)
|
data/README.md
CHANGED
@@ -36,8 +36,6 @@ Handler functions (`find_pet`) are called with two arguments:
|
|
36
36
|
- `res` - Holds a Rack::Response that you can modify if needed
|
37
37
|
If you want to access to plain Rack env you can call `params.env`.
|
38
38
|
|
39
|
-
You can also use the provided Rack middlewares to auto-implement only certain aspects of the request-response flow like query parameter or request body parameter validation based on your OpenAPI file. Read on to learn how.
|
40
|
-
|
41
39
|
### Handling only certain paths
|
42
40
|
|
43
41
|
You can filter the URIs that should be handled by pass ing `only` to `OpenapiFirst.load`:
|
@@ -57,9 +55,9 @@ run OpenapiFirst.middleware('./openapi/openapi.yaml', namespace: Pets)
|
|
57
55
|
|
58
56
|
When using the middleware, all requests that are not part of the API description will be passed to the next app.
|
59
57
|
|
60
|
-
|
58
|
+
### Try it out
|
61
59
|
|
62
|
-
See [
|
60
|
+
See [examples](examples).
|
63
61
|
|
64
62
|
|
65
63
|
## Installation
|
@@ -72,21 +70,31 @@ gem 'openapi_first'
|
|
72
70
|
|
73
71
|
OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
|
74
72
|
|
75
|
-
##
|
73
|
+
## Handlers
|
76
74
|
|
77
|
-
OpenapiFirst
|
75
|
+
OpenapiFirst maps the HTTP request to a method call based on the [operationId](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operation-object) in your API description.
|
78
76
|
|
79
|
-
|
80
|
-
- Mapping request to a function call
|
77
|
+
It works like this:
|
81
78
|
|
82
|
-
|
79
|
+
- "create_pet" or "createPet" or "create pet" calls `MyApi.create_pet(params, response)`
|
80
|
+
- "some_things.create" calls: `MyApi::SomeThings.create(params, response)`
|
81
|
+
- "pets#create" calls: `MyApi::Pets::Create.new.call(params, response)`
|
83
82
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
83
|
+
These handler methods are called with two arguments:
|
84
|
+
|
85
|
+
- `params` - Holds the parsed request body, filtered query params and path parameters
|
86
|
+
- `res` - Holds a Rack::Response that you can modify if needed
|
87
|
+
|
88
|
+
You can call `params.env` to access the Rack env (just like in [Hanami actions](https://guides.hanamirb.org/actions/parameters/))
|
89
|
+
|
90
|
+
There are two ways to set the response body:
|
88
91
|
|
89
|
-
|
92
|
+
- Calling `res.write "things"` (see [Rack::Response](https://www.rubydoc.info/github/rack/rack/Rack/Response))
|
93
|
+
- Returning a value from the function (see example above) (this will always converted to JSON)
|
94
|
+
|
95
|
+
## Request validation
|
96
|
+
|
97
|
+
If the request is not valid, these middlewares return a 400 status code with a body that describes the error.
|
90
98
|
|
91
99
|
The error responses conform with [JSON:API](https://jsonapi.org).
|
92
100
|
|
@@ -107,18 +115,12 @@ content-type: "application/vnd.api+json"
|
|
107
115
|
]
|
108
116
|
}
|
109
117
|
```
|
110
|
-
## Request validation
|
111
|
-
|
112
|
-
```ruby
|
113
|
-
# Add the middleware:
|
114
|
-
use OpenapiFirst::RequestValidation
|
115
|
-
```
|
116
118
|
|
117
|
-
|
119
|
+
### Parameter validation
|
118
120
|
|
119
|
-
|
121
|
+
The middleware filteres all top-level query parameters and paths parameters and tries to convert numeric values. Meaning, if you have an `:something_id` path with `type: integer`, it will try convert the value to an integer.
|
122
|
+
Note that is currently does not convert date, date-time or time formats and that conversion is currently on done for path and query parameters, but not for request bodies.
|
120
123
|
|
121
|
-
The middleware filteres all top-level query parameters and adds these to the Rack env: `env[OpenapiFirst::QUERY_PARAMS]`.
|
122
124
|
If you want to forbid _nested_ query parameters you will need to use [`additionalProperties: false`](https://json-schema.org/understanding-json-schema/reference/object.html#properties) in your query parameter JSON schema.
|
123
125
|
|
124
126
|
_OpenapiFirst always treats query parameters like [`style: deepObject`](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#style-values), **but** it just works with nested objects (`filter[foo][bar]=baz`) (see [this discussion](https://github.com/OAI/OpenAPI-Specification/issues/1706))._
|
@@ -132,54 +134,6 @@ This will add the parsed request body to `env[OpenapiFirst::REQUEST_BODY]`.
|
|
132
134
|
|
133
135
|
tbd.
|
134
136
|
|
135
|
-
## Mapping the request to a method call
|
136
|
-
|
137
|
-
OpenapiFirst uses a `OperationResolver` middleware to map the HTTP request to a method call.
|
138
|
-
|
139
|
-
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 like this:
|
140
|
-
|
141
|
-
- `create_pet` will map to `MyApi.create_pet(params, response)`
|
142
|
-
- `some_things.create` will map to `MyApi::SomeThings.create(params, response)`
|
143
|
-
- `pets#create` will map to `MyApi::Pets::Create.new.call(params, response)` (like [Hanami::Router](https://github.com/hanami/router#controllers))
|
144
|
-
|
145
|
-
These handler methods are called with two arguments:
|
146
|
-
|
147
|
-
- `params` - Holds the parsed request body, filtered query params and path parameters
|
148
|
-
- `res` - Holds a Rack::Response that you can modify if needed
|
149
|
-
|
150
|
-
You can call `params.env` to access the Rack env (just like in [Hanami actions](https://guides.hanamirb.org/actions/parameters/))
|
151
|
-
|
152
|
-
There are two ways to set the response body:
|
153
|
-
|
154
|
-
- Calling `res.write "things"` (see [Rack::Response](https://www.rubydoc.info/github/rack/rack/Rack/Response))
|
155
|
-
- Returning a value from the function (see example above) (this will always converted to JSON)
|
156
|
-
|
157
|
-
### Adding the middleware
|
158
|
-
|
159
|
-
```ruby
|
160
|
-
# Define some methods
|
161
|
-
module MyApi
|
162
|
-
def self.create_pet(params, res)
|
163
|
-
res.status = 201
|
164
|
-
{
|
165
|
-
id: '1',
|
166
|
-
name: params['name']
|
167
|
-
}
|
168
|
-
end
|
169
|
-
end
|
170
|
-
|
171
|
-
# Add the middleware:
|
172
|
-
use OpenapiFirst::OperationResolver, namespace: MyApi
|
173
|
-
# If the operation was not found in the OAS file, the next app will be called
|
174
|
-
|
175
|
-
# OR use it as a Rack app via `run`:
|
176
|
-
run OpenapiFirst::OperationResolver, namespace: Pets
|
177
|
-
# If the operation was not found, this will return 404
|
178
|
-
|
179
|
-
# Now make a request like
|
180
|
-
# POST /pets, { name: 'Oscar' }
|
181
|
-
```
|
182
|
-
|
183
137
|
## Response validation
|
184
138
|
|
185
139
|
Response validation is useful to make sure your app responds as described in your API description. You usually do this in your tests using [rack-test](https://github.com/rack-test/rack-test).
|
data/benchmarks/Gemfile
CHANGED
data/benchmarks/Gemfile.lock
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
openapi_first (0.
|
4
|
+
openapi_first (0.7.0.alpha1)
|
5
|
+
hanami-router (~> 2.0.alpha2)
|
6
|
+
hanami-utils (~> 2.0.alpha1)
|
5
7
|
json_schemer (~> 0.2)
|
6
8
|
multi_json (~> 1.14)
|
7
|
-
mustermann-contrib (~> 1.1.1)
|
8
9
|
oas_parser (~> 0.24)
|
9
10
|
rack (~> 2.2)
|
10
11
|
|
@@ -44,7 +45,7 @@ GEM
|
|
44
45
|
concurrent-ruby (~> 1.0)
|
45
46
|
dry-core (~> 0.2)
|
46
47
|
dry-equalizer (~> 0.2)
|
47
|
-
dry-types (1.
|
48
|
+
dry-types (1.4.0)
|
48
49
|
concurrent-ruby (~> 1.0)
|
49
50
|
dry-container (~> 0.3)
|
50
51
|
dry-core (~> 0.4, >= 0.4.4)
|
@@ -65,12 +66,15 @@ GEM
|
|
65
66
|
mustermann (~> 1.0)
|
66
67
|
mustermann-contrib (~> 1.0)
|
67
68
|
rack (~> 2.0)
|
69
|
+
hanami-utils (2.0.0.alpha1)
|
70
|
+
concurrent-ruby (~> 1.0)
|
71
|
+
transproc (~> 1.0)
|
68
72
|
hansi (0.2.0)
|
69
73
|
hash-deep-merge (0.1.1)
|
70
74
|
i18n (1.8.2)
|
71
75
|
concurrent-ruby (~> 1.0)
|
72
76
|
json_schema (0.20.8)
|
73
|
-
json_schemer (0.2.
|
77
|
+
json_schemer (0.2.11)
|
74
78
|
ecma-re-validator (~> 0.2)
|
75
79
|
hana (~> 1.3)
|
76
80
|
regexp_parser (~> 1.5)
|
@@ -116,6 +120,7 @@ GEM
|
|
116
120
|
seg
|
117
121
|
thread_safe (0.3.6)
|
118
122
|
tilt (2.0.10)
|
123
|
+
transproc (1.1.1)
|
119
124
|
tzinfo (1.2.6)
|
120
125
|
thread_safe (~> 0.1)
|
121
126
|
uri_template (0.7.0)
|
@@ -129,7 +134,7 @@ DEPENDENCIES
|
|
129
134
|
benchmark-memory
|
130
135
|
committee
|
131
136
|
grape
|
132
|
-
hanami-router (~> 2.0.0.
|
137
|
+
hanami-router (~> 2.0.0.alpha2)
|
133
138
|
multi_json
|
134
139
|
openapi_first!
|
135
140
|
sinatra
|
@@ -19,5 +19,5 @@ namespace = Module.new do
|
|
19
19
|
end
|
20
20
|
|
21
21
|
spec = OpenapiFirst.load(File.absolute_path('./openapi.yaml', __dir__))
|
22
|
-
use OpenapiFirst::Router, spec: spec
|
23
|
-
run OpenapiFirst::OperationResolver.new
|
22
|
+
use OpenapiFirst::Router, spec: spec, namespace: namespace
|
23
|
+
run OpenapiFirst::OperationResolver.new
|
data/lib/openapi_first/app.rb
CHANGED
@@ -5,18 +5,18 @@ require 'rack'
|
|
5
5
|
module OpenapiFirst
|
6
6
|
class App
|
7
7
|
def initialize(
|
8
|
-
|
8
|
+
parent_app,
|
9
9
|
spec,
|
10
|
-
namespace
|
11
|
-
allow_unknown_operation: !app.nil?
|
10
|
+
namespace:
|
12
11
|
)
|
13
12
|
@stack = Rack::Builder.app do
|
14
13
|
freeze_app
|
15
14
|
use OpenapiFirst::Router,
|
16
15
|
spec: spec,
|
17
|
-
|
16
|
+
namespace: namespace,
|
17
|
+
parent_app: parent_app
|
18
18
|
use OpenapiFirst::RequestValidation
|
19
|
-
run OpenapiFirst::OperationResolver.new
|
19
|
+
run OpenapiFirst::OperationResolver.new
|
20
20
|
end
|
21
21
|
end
|
22
22
|
|
@@ -13,15 +13,16 @@ module OpenapiFirst
|
|
13
13
|
end
|
14
14
|
|
15
15
|
def call(env)
|
16
|
-
|
16
|
+
response = @app.call(env)
|
17
|
+
operation = env[OPERATION]
|
17
18
|
@to_be_called.delete(endpoint_id(operation)) if operation
|
18
|
-
|
19
|
+
response
|
19
20
|
end
|
20
21
|
|
21
22
|
private
|
22
23
|
|
23
24
|
def endpoint_id(operation)
|
24
|
-
"#{operation.path
|
25
|
+
"#{operation.path}##{operation.method}"
|
25
26
|
end
|
26
27
|
end
|
27
28
|
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'operation'
|
4
|
+
|
3
5
|
module OpenapiFirst
|
4
6
|
class Definition
|
5
7
|
def initialize(parsed)
|
@@ -7,7 +9,7 @@ module OpenapiFirst
|
|
7
9
|
end
|
8
10
|
|
9
11
|
def operations
|
10
|
-
@spec.endpoints
|
12
|
+
@spec.endpoints.map { |e| Operation.new(e) }
|
11
13
|
end
|
12
14
|
|
13
15
|
def find_operation!(request)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class Operation
|
7
|
+
extend Forwardable
|
8
|
+
def_delegators :@operation,
|
9
|
+
:parameters,
|
10
|
+
:method,
|
11
|
+
:request_body,
|
12
|
+
:operation_id
|
13
|
+
|
14
|
+
def initialize(parsed)
|
15
|
+
@operation = parsed
|
16
|
+
end
|
17
|
+
|
18
|
+
def path
|
19
|
+
@operation.path.path
|
20
|
+
end
|
21
|
+
|
22
|
+
def parameters_json_schema
|
23
|
+
@parameters_json_schema ||= build_parameters_json_schema
|
24
|
+
end
|
25
|
+
|
26
|
+
def content_type_for(status)
|
27
|
+
content = @operation
|
28
|
+
.response_by_code(status.to_s, use_default: true)
|
29
|
+
.content
|
30
|
+
content.keys[0] if content
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def build_parameters_json_schema
|
36
|
+
return unless @operation.parameters&.any?
|
37
|
+
|
38
|
+
@operation.parameters.each_with_object(
|
39
|
+
'type' => 'object',
|
40
|
+
'required' => [],
|
41
|
+
'properties' => {}
|
42
|
+
) do |parameter, schema|
|
43
|
+
schema['required'] << parameter.name if parameter.required
|
44
|
+
schema['properties'][parameter.name] = parameter.schema
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -4,55 +4,28 @@ require 'rack'
|
|
4
4
|
|
5
5
|
module OpenapiFirst
|
6
6
|
class OperationResolver
|
7
|
-
|
8
|
-
DEFAULT_APP = ->(_env) { NOT_FOUND }
|
9
|
-
|
10
|
-
def initialize(app = DEFAULT_APP, options) # rubocop:disable Style/OptionalArguments
|
11
|
-
@app = app
|
12
|
-
@namespace = options.fetch(:namespace)
|
13
|
-
end
|
14
|
-
|
15
|
-
def call(env) # rubocop:disable Metrics/AbcSize
|
7
|
+
def call(env)
|
16
8
|
operation = env[OpenapiFirst::OPERATION]
|
17
|
-
return @app.call(env) unless operation
|
18
|
-
|
19
|
-
operation_id = operation.operation_id
|
20
9
|
res = Rack::Response.new
|
21
10
|
params = build_params(env)
|
22
|
-
handler =
|
11
|
+
handler = env[HANDLER]
|
23
12
|
result = handler.call(params, res)
|
24
|
-
res.write
|
25
|
-
res[Rack::CONTENT_TYPE] ||=
|
13
|
+
res.write serialize(result) if result && res.body.empty?
|
14
|
+
res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
|
26
15
|
res.finish
|
27
16
|
end
|
28
17
|
|
29
|
-
def find_handler(operation_id)
|
30
|
-
if operation_id.include?('.')
|
31
|
-
module_name, method_name = operation_id.split('.')
|
32
|
-
return @namespace.const_get(module_name.camelize).method(method_name)
|
33
|
-
end
|
34
|
-
|
35
|
-
if operation_id.include?('#')
|
36
|
-
module_name, class_name = operation_id.split('#')
|
37
|
-
return @namespace.const_get(module_name.camelize)
|
38
|
-
.const_get(class_name.camelize).new
|
39
|
-
end
|
40
|
-
@namespace.method(operation_id)
|
41
|
-
end
|
42
|
-
|
43
18
|
private
|
44
19
|
|
45
|
-
def
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
content.keys[0] if content
|
20
|
+
def serialize(result)
|
21
|
+
return result if result.is_a?(String)
|
22
|
+
|
23
|
+
MultiJson.dump(result)
|
50
24
|
end
|
51
25
|
|
52
26
|
def build_params(env)
|
53
27
|
sources = [
|
54
|
-
env[
|
55
|
-
env[QUERY_PARAMS],
|
28
|
+
env[PARAMS],
|
56
29
|
env[REQUEST_BODY]
|
57
30
|
].tap(&:compact!)
|
58
31
|
Params.new(env).merge!(*sources)
|
@@ -64,7 +37,7 @@ module OpenapiFirst
|
|
64
37
|
|
65
38
|
def initialize(env)
|
66
39
|
@env = env
|
67
|
-
super
|
40
|
+
super
|
68
41
|
end
|
69
42
|
end
|
70
43
|
end
|
@@ -3,25 +3,21 @@
|
|
3
3
|
require 'rack'
|
4
4
|
require 'json_schemer'
|
5
5
|
require 'multi_json'
|
6
|
-
require_relative 'query_parameters'
|
7
6
|
require_relative 'validation_format'
|
8
7
|
|
9
8
|
module OpenapiFirst
|
10
|
-
class RequestValidation
|
11
|
-
def initialize(app,
|
9
|
+
class RequestValidation # rubocop:disable Metrics/ClassLength
|
10
|
+
def initialize(app, _options = {})
|
12
11
|
@app = app
|
13
|
-
@allow_unknown_query_parameters = options.fetch(
|
14
|
-
:allow_unknown_query_parameters, false
|
15
|
-
)
|
16
12
|
end
|
17
13
|
|
18
14
|
def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
19
15
|
operation = env[OpenapiFirst::OPERATION]
|
20
16
|
return @app.call(env) unless operation
|
21
17
|
|
22
|
-
req = Rack::Request.new(env)
|
23
18
|
catch(:halt) do
|
24
|
-
validate_query_parameters!(env, operation,
|
19
|
+
validate_query_parameters!(env, operation, env[PARAMS])
|
20
|
+
req = Rack::Request.new(env)
|
25
21
|
content_type = req.content_type
|
26
22
|
return @app.call(env) unless operation.request_body
|
27
23
|
|
@@ -33,6 +29,8 @@ module OpenapiFirst
|
|
33
29
|
end
|
34
30
|
end
|
35
31
|
|
32
|
+
private
|
33
|
+
|
36
34
|
def halt(response)
|
37
35
|
throw :halt, response
|
38
36
|
end
|
@@ -83,10 +81,10 @@ module OpenapiFirst
|
|
83
81
|
).finish
|
84
82
|
end
|
85
83
|
|
86
|
-
def request_body_schema(content_type,
|
87
|
-
return unless
|
84
|
+
def request_body_schema(content_type, operation)
|
85
|
+
return unless operation
|
88
86
|
|
89
|
-
|
87
|
+
operation.request_body.content[content_type]&.fetch('schema')
|
90
88
|
end
|
91
89
|
|
92
90
|
def serialize_request_body_errors(validation_errors)
|
@@ -100,27 +98,25 @@ module OpenapiFirst
|
|
100
98
|
end
|
101
99
|
|
102
100
|
def validate_query_parameters!(env, operation, params)
|
103
|
-
json_schema =
|
104
|
-
operation: operation,
|
105
|
-
allow_unknown_parameters: @allow_unknown_query_parameters
|
106
|
-
).to_json_schema
|
107
|
-
|
101
|
+
json_schema = operation.parameters_json_schema
|
108
102
|
return unless json_schema
|
109
103
|
|
104
|
+
params = filtered_params(json_schema, params)
|
110
105
|
errors = JSONSchemer.schema(json_schema).validate(params)
|
111
106
|
if errors.any?
|
112
107
|
halt error_response(400, serialize_query_parameter_errors(errors))
|
113
108
|
end
|
114
|
-
env[
|
109
|
+
env[PARAMS] = params
|
115
110
|
end
|
116
111
|
|
117
|
-
def
|
112
|
+
def filtered_params(json_schema, params)
|
118
113
|
json_schema['properties']
|
119
|
-
.
|
120
|
-
|
114
|
+
.each_with_object({}) do |key_value, result|
|
115
|
+
parameter_name, schema = key_value
|
121
116
|
next unless params.key?(parameter_name)
|
122
117
|
|
123
|
-
|
118
|
+
value = params[parameter_name]
|
119
|
+
result[parameter_name] = parse_parameter(value, schema)
|
124
120
|
end
|
125
121
|
end
|
126
122
|
|
@@ -131,5 +127,18 @@ module OpenapiFirst
|
|
131
127
|
}.update(ValidationFormat.error_details(error))
|
132
128
|
end
|
133
129
|
end
|
130
|
+
|
131
|
+
def parse_parameter(value, schema)
|
132
|
+
return filtered_params(schema, value) if schema['properties']
|
133
|
+
|
134
|
+
begin
|
135
|
+
return Integer(value, 10) if schema['type'] == 'integer'
|
136
|
+
return Float(value) if schema['type'] == 'number'
|
137
|
+
rescue ArgumentError
|
138
|
+
value
|
139
|
+
end
|
140
|
+
|
141
|
+
value
|
142
|
+
end
|
134
143
|
end
|
135
144
|
end
|
data/lib/openapi_first/router.rb
CHANGED
@@ -1,9 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rack'
|
4
|
-
require '
|
5
|
-
|
6
|
-
require 'mustermann/template'
|
4
|
+
require 'hanami/router'
|
5
|
+
require_relative 'utils'
|
7
6
|
|
8
7
|
module OpenapiFirst
|
9
8
|
class Router
|
@@ -11,25 +10,75 @@ module OpenapiFirst
|
|
11
10
|
|
12
11
|
def initialize(app, options)
|
13
12
|
@app = app
|
14
|
-
@
|
15
|
-
@
|
13
|
+
@namespace = options.fetch(:namespace)
|
14
|
+
@parent_app = options.fetch(:parent_app, nil)
|
15
|
+
@router = build_router(options.fetch(:spec).operations)
|
16
16
|
end
|
17
17
|
|
18
18
|
def call(env)
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
env
|
23
|
-
return @app.call(env) if operation || @allow_unknown_operation
|
19
|
+
route = @router.recognize(env)
|
20
|
+
return route.endpoint.call(env) if route.routable?
|
21
|
+
|
22
|
+
return @parent_app.call(env) if @parent_app
|
24
23
|
|
25
24
|
NOT_FOUND
|
26
25
|
end
|
27
26
|
|
28
|
-
def
|
29
|
-
|
27
|
+
def find_handler(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
28
|
+
name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
|
29
|
+
return if name.nil?
|
30
|
+
|
31
|
+
if name.include?('.')
|
32
|
+
module_name, method_name = name.split('.')
|
33
|
+
klass = find_const(@namespace, module_name)
|
34
|
+
return klass&.method(Utils.underscore(method_name))
|
35
|
+
end
|
36
|
+
if name.include?('#')
|
37
|
+
module_name, klass_name = name.split('#')
|
38
|
+
const = find_const(@namespace, module_name)
|
39
|
+
klass = find_const(const, klass_name)
|
40
|
+
return ->(params, res) { klass.new.call(params, res) }
|
41
|
+
end
|
42
|
+
method_name = Utils.underscore(name)
|
43
|
+
return unless @namespace.respond_to?(method_name)
|
44
|
+
|
45
|
+
@namespace.method(method_name)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def find_const(parent, name)
|
51
|
+
name = Utils.classify(name)
|
52
|
+
return unless parent.const_defined?(name, false)
|
53
|
+
|
54
|
+
parent.const_get(name, false)
|
55
|
+
end
|
30
56
|
|
31
|
-
|
32
|
-
|
57
|
+
def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
58
|
+
router = Hanami::Router.new {}
|
59
|
+
operations.each do |operation|
|
60
|
+
normalized_path = operation.path.gsub('{', ':').gsub('}', '')
|
61
|
+
if operation.operation_id.nil?
|
62
|
+
warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation." # rubocop:disable Layout/LineLength
|
63
|
+
next
|
64
|
+
end
|
65
|
+
handler = find_handler(operation.operation_id)
|
66
|
+
if handler.nil?
|
67
|
+
warn "could not find handler for '#{operation.operation_id}' (#{operation.method} #{operation.path}). I am ignoring this operation." # rubocop:disable Layout/LineLength
|
68
|
+
next
|
69
|
+
end
|
70
|
+
router.public_send(
|
71
|
+
operation.method,
|
72
|
+
normalized_path,
|
73
|
+
to: lambda do |env|
|
74
|
+
env[OPERATION] = operation
|
75
|
+
env[PARAMS] = Utils.deep_stringify(env['router.params'])
|
76
|
+
env[HANDLER] = handler
|
77
|
+
@app.call(env)
|
78
|
+
end
|
79
|
+
)
|
80
|
+
end
|
81
|
+
router
|
33
82
|
end
|
34
83
|
end
|
35
84
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'hanami/utils/string'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
module Utils
|
7
|
+
def self.underscore(string)
|
8
|
+
Hanami::Utils::String.underscore(string)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.classify(string)
|
12
|
+
Hanami::Utils::String.classify(string)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.deep_stringify(params) # rubocop:disable Metrics/MethodLength
|
16
|
+
params.each_with_object({}) do |(key, value), output|
|
17
|
+
output[key.to_s] =
|
18
|
+
case value
|
19
|
+
when ::Hash
|
20
|
+
deep_stringify(value)
|
21
|
+
when Array
|
22
|
+
value.map do |item|
|
23
|
+
item.is_a?(::Hash) ? deep_stringify(item) : item
|
24
|
+
end
|
25
|
+
else
|
26
|
+
value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/openapi_first.rb
CHANGED
@@ -12,9 +12,9 @@ require 'openapi_first/app'
|
|
12
12
|
|
13
13
|
module OpenapiFirst
|
14
14
|
OPERATION = 'openapi_first.operation'
|
15
|
-
|
15
|
+
PARAMS = 'openapi_first.params'
|
16
16
|
REQUEST_BODY = 'openapi_first.parsed_request_body'
|
17
|
-
|
17
|
+
HANDLER = 'openapi_first.handler'
|
18
18
|
|
19
19
|
def self.load(spec_path, only: nil)
|
20
20
|
content = YAML.load_file(spec_path)
|
data/openapi_first.gemspec
CHANGED
@@ -32,9 +32,10 @@ Gem::Specification.new do |spec|
|
|
32
32
|
spec.bindir = 'exe'
|
33
33
|
spec.require_paths = ['lib']
|
34
34
|
|
35
|
+
spec.add_dependency 'hanami-router', '~> 2.0.alpha2'
|
36
|
+
spec.add_dependency 'hanami-utils', '~> 2.0.alpha1'
|
35
37
|
spec.add_dependency 'json_schemer', '~> 0.2'
|
36
38
|
spec.add_dependency 'multi_json', '~> 1.14'
|
37
|
-
spec.add_dependency 'mustermann-contrib', '~> 1.1.1'
|
38
39
|
spec.add_dependency 'oas_parser', '~> 0.24'
|
39
40
|
spec.add_dependency 'rack', '~> 2.2'
|
40
41
|
|
metadata
CHANGED
@@ -1,57 +1,71 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openapi_first
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0.alpha1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andreas Haller
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-03-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: hanami-router
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 2.0.alpha2
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 2.0.alpha2
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: hanami-utils
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: 2.0.alpha1
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: 2.0.alpha1
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: json_schemer
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - "~>"
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version:
|
47
|
+
version: '0.2'
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - "~>"
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version:
|
54
|
+
version: '0.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: multi_json
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.14'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.14'
|
55
69
|
- !ruby/object:Gem::Dependency
|
56
70
|
name: oas_parser
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -160,7 +174,6 @@ files:
|
|
160
174
|
- benchmarks/apps/hanami_router.ru
|
161
175
|
- benchmarks/apps/openapi.yaml
|
162
176
|
- benchmarks/apps/openapi_first.ru
|
163
|
-
- benchmarks/apps/openapi_first_request_validation_only.ru
|
164
177
|
- benchmarks/apps/openapi_first_resolve_only.ru
|
165
178
|
- benchmarks/apps/sinatra.ru
|
166
179
|
- benchmarks/apps/syro.ru
|
@@ -175,11 +188,12 @@ files:
|
|
175
188
|
- lib/openapi_first/app.rb
|
176
189
|
- lib/openapi_first/coverage.rb
|
177
190
|
- lib/openapi_first/definition.rb
|
191
|
+
- lib/openapi_first/operation.rb
|
178
192
|
- lib/openapi_first/operation_resolver.rb
|
179
|
-
- lib/openapi_first/query_parameters.rb
|
180
193
|
- lib/openapi_first/request_validation.rb
|
181
194
|
- lib/openapi_first/response_validator.rb
|
182
195
|
- lib/openapi_first/router.rb
|
196
|
+
- lib/openapi_first/utils.rb
|
183
197
|
- lib/openapi_first/validation.rb
|
184
198
|
- lib/openapi_first/validation_format.rb
|
185
199
|
- lib/openapi_first/version.rb
|
@@ -202,9 +216,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
202
216
|
version: '0'
|
203
217
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
204
218
|
requirements:
|
205
|
-
- - "
|
219
|
+
- - ">"
|
206
220
|
- !ruby/object:Gem::Version
|
207
|
-
version:
|
221
|
+
version: 1.3.1
|
208
222
|
requirements: []
|
209
223
|
rubygems_version: 3.1.2
|
210
224
|
signing_key:
|
@@ -1,30 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'openapi_first'
|
4
|
-
require 'syro'
|
5
|
-
require 'multi_json'
|
6
|
-
|
7
|
-
app = Syro.new do
|
8
|
-
on 'hello' do
|
9
|
-
on :id do
|
10
|
-
get do
|
11
|
-
res.json MultiJson.dump(hello: 'world', id: inbox[:id])
|
12
|
-
end
|
13
|
-
end
|
14
|
-
|
15
|
-
get do
|
16
|
-
res.json [MultiJson.dump(hello: 'world')]
|
17
|
-
end
|
18
|
-
|
19
|
-
post do
|
20
|
-
res.status = 201
|
21
|
-
res.json MultiJson.dump(hello: 'world')
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
spec = OpenapiFirst.load(File.absolute_path('./openapi.yaml', __dir__))
|
27
|
-
use OpenapiFirst::Router, spec: spec
|
28
|
-
use OpenapiFirst::RequestValidation
|
29
|
-
|
30
|
-
run app
|
@@ -1,24 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
class QueryParameters
|
5
|
-
def initialize(operation:, allow_unknown_parameters: false)
|
6
|
-
@operation = operation
|
7
|
-
@allow_unknown_parameters = allow_unknown_parameters
|
8
|
-
end
|
9
|
-
|
10
|
-
def to_json_schema
|
11
|
-
return unless @operation&.query_parameters&.any?
|
12
|
-
|
13
|
-
@operation.query_parameters.each_with_object(
|
14
|
-
'type' => 'object',
|
15
|
-
'required' => [],
|
16
|
-
'additionalProperties' => @allow_unknown_parameters,
|
17
|
-
'properties' => {}
|
18
|
-
) do |parameter, schema|
|
19
|
-
schema['required'] << parameter.name if parameter.required
|
20
|
-
schema['properties'][parameter.name] = parameter.schema
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|