openapi_first 0.6.10 → 0.7.0.alpha1
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 +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
|