openapi_first 1.0.0 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 259995bd946bd53afc1b6ab3cc2d556811a8d775ba2714d1e13f0058f3049048
4
- data.tar.gz: fe150a639f4ddbbf6190296c0fb70ee06c5116711a2716826934fc12b48b1622
3
+ metadata.gz: 5ee379d839bec13bd899f690b3275d761b7425461f2241da4ccb62de384f0c94
4
+ data.tar.gz: 6ea27d437c7cb443d5b5c84b8e6d0ef34782e4f5d8bb5ce7666acc78797ca523
5
5
  SHA512:
6
- metadata.gz: ed09d09b5ad4c131134843ed0c7772eb6742b9bd98e6bb410d2c7fca3efe9a49875993b095a9590ee1a5ec9484301d5a58ed67b445fdd46aacbabedaf7e8160e
7
- data.tar.gz: 9fe5c08c823aff23d979b9cd0652e4a63a8df424f98c037cb77e45c28c7fd232f24305df8024495b7bd50f4b99fbb8669d0f8a962b20ef2652907298ac7dd6cf
6
+ metadata.gz: ab20a8bcd7d61961f4a1d654a36291f295ee0d69c50829cd8f1965998ca9a480a0636e2b0ca62d220a53c62b60d8bedb934fc8a57bed39a8e45683796a5cee8a
7
+ data.tar.gz: 43206c1bf540193863bd1f02c14f79d172fff662974c8efbfd90dfd6334f11f23c0dee62c9c70c4bd45bbf202e68a76c9750a00aa4a45f5dd8682b6837a1177b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ - Added `OpenapiFirst.parse(hash)` to load ("parse") a resolved/de-referenced Hash
6
+ - Added support for unescaped special characters in the path params (https://github.com/ahx/openapi_first/pull/217)
7
+ - Added `operation` to `RuntimeRequest` by [@MrBananaLord](https://github.com/ahx/openapi_first/pull/216)
8
+
9
+ ## 1.1.1
10
+
11
+ - Fix reading response body for example when running Rails (`ActionDispatch::Response::RackBody`)
12
+ - Add `known?`, `status`, `body`, `headers`, `content_type` methods to inspect the parsed response (`RuntimeResponse`)
13
+ - Add `OpenapiFirst::ParseError` which is raised by low-level interfaces like `request.body` if the body could not be parsed.
14
+ - Add "code" field to errors in JSON:API error response
15
+
16
+ ## 1.1.0 (yanked)
17
+
3
18
  ## 1.0.0
4
19
 
5
20
  - Breaking: The default error uses application/problem+json content-type
data/Gemfile CHANGED
@@ -2,15 +2,17 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- # Specify your gem's dependencies in openapi_first.gemspec
6
5
  gemspec
7
6
 
8
7
  gem 'rack', '>= 3.0.0'
8
+ gem 'rackup'
9
9
 
10
10
  group :test, :development do
11
+ gem 'actionpack'
11
12
  gem 'bundler'
12
13
  gem 'rack-test'
13
14
  gem 'rake'
14
15
  gem 'rspec'
15
16
  gem 'rubocop'
17
+ gem 'simplecov'
16
18
  end
data/Gemfile.lock CHANGED
@@ -1,21 +1,58 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (1.0.0)
4
+ openapi_first (1.2.1)
5
5
  json_refs (~> 0.1, >= 0.1.7)
6
6
  json_schemer (~> 2.1.0)
7
7
  multi_json (~> 1.15)
8
- mustermann-contrib (~> 3.0.0)
8
+ mustermann (~> 3.0.0)
9
9
  openapi_parameters (>= 0.3.2, < 2.0)
10
10
  rack (>= 2.2, < 4.0)
11
11
 
12
12
  GEM
13
13
  remote: https://rubygems.org/
14
14
  specs:
15
+ actionpack (7.1.3)
16
+ actionview (= 7.1.3)
17
+ activesupport (= 7.1.3)
18
+ nokogiri (>= 1.8.5)
19
+ racc
20
+ rack (>= 2.2.4)
21
+ rack-session (>= 1.0.1)
22
+ rack-test (>= 0.6.3)
23
+ rails-dom-testing (~> 2.2)
24
+ rails-html-sanitizer (~> 1.6)
25
+ actionview (7.1.3)
26
+ activesupport (= 7.1.3)
27
+ builder (~> 3.1)
28
+ erubi (~> 1.11)
29
+ rails-dom-testing (~> 2.2)
30
+ rails-html-sanitizer (~> 1.6)
31
+ activesupport (7.1.3)
32
+ base64
33
+ bigdecimal
34
+ concurrent-ruby (~> 1.0, >= 1.0.2)
35
+ connection_pool (>= 2.2.5)
36
+ drb
37
+ i18n (>= 1.6, < 2)
38
+ minitest (>= 5.1)
39
+ mutex_m
40
+ tzinfo (~> 2.0)
15
41
  ast (2.4.2)
42
+ base64 (0.2.0)
43
+ bigdecimal (3.1.6)
44
+ builder (3.2.4)
45
+ concurrent-ruby (1.2.3)
46
+ connection_pool (2.4.1)
47
+ crass (1.0.6)
16
48
  diff-lcs (1.5.0)
49
+ docile (1.4.0)
50
+ drb (2.2.0)
51
+ ruby2_keywords
52
+ erubi (1.12.0)
17
53
  hana (1.3.7)
18
- hansi (0.2.1)
54
+ i18n (1.14.1)
55
+ concurrent-ruby (~> 1.0)
19
56
  json (2.7.1)
20
57
  json_refs (0.1.8)
21
58
  hana
@@ -24,26 +61,44 @@ GEM
24
61
  regexp_parser (~> 2.0)
25
62
  simpleidn (~> 0.2)
26
63
  language_server-protocol (3.17.0.3)
64
+ loofah (2.22.0)
65
+ crass (~> 1.0.2)
66
+ nokogiri (>= 1.12.0)
67
+ minitest (5.21.2)
27
68
  multi_json (1.15.0)
28
69
  mustermann (3.0.0)
29
70
  ruby2_keywords (~> 0.0.1)
30
- mustermann-contrib (3.0.0)
31
- hansi (~> 0.2.0)
32
- mustermann (= 3.0.0)
71
+ mutex_m (0.2.0)
72
+ nokogiri (1.16.0-arm64-darwin)
73
+ racc (~> 1.4)
74
+ nokogiri (1.16.0-x86_64-linux)
75
+ racc (~> 1.4)
33
76
  openapi_parameters (0.3.2)
34
77
  rack (>= 2.2)
35
78
  zeitwerk (~> 2.6)
36
79
  parallel (1.24.0)
37
- parser (3.3.0.0)
80
+ parser (3.3.0.5)
38
81
  ast (~> 2.4.1)
39
82
  racc
40
83
  racc (1.7.3)
41
84
  rack (3.0.8)
85
+ rack-session (2.0.0)
86
+ rack (>= 3.0.0)
42
87
  rack-test (2.1.0)
43
88
  rack (>= 1.3)
89
+ rackup (2.1.0)
90
+ rack (>= 3)
91
+ webrick (~> 1.8)
92
+ rails-dom-testing (2.2.0)
93
+ activesupport (>= 5.0.0)
94
+ minitest
95
+ nokogiri (>= 1.6)
96
+ rails-html-sanitizer (1.6.0)
97
+ loofah (~> 2.21)
98
+ nokogiri (~> 1.14)
44
99
  rainbow (3.1.1)
45
100
  rake (13.1.0)
46
- regexp_parser (2.8.3)
101
+ regexp_parser (2.9.0)
47
102
  rexml (3.2.6)
48
103
  rspec (3.12.0)
49
104
  rspec-core (~> 3.12.0)
@@ -58,11 +113,11 @@ GEM
58
113
  diff-lcs (>= 1.2.0, < 2.0)
59
114
  rspec-support (~> 3.12.0)
60
115
  rspec-support (3.12.1)
61
- rubocop (1.59.0)
116
+ rubocop (1.60.1)
62
117
  json (~> 2.3)
63
118
  language_server-protocol (>= 3.17.0)
64
119
  parallel (~> 1.10)
65
- parser (>= 3.2.2.4)
120
+ parser (>= 3.3.0.2)
66
121
  rainbow (>= 2.2.2, < 4.0)
67
122
  regexp_parser (>= 1.8, < 3.0)
68
123
  rexml (>= 3.2.5, < 4.0)
@@ -73,12 +128,21 @@ GEM
73
128
  parser (>= 3.2.1.0)
74
129
  ruby-progressbar (1.13.0)
75
130
  ruby2_keywords (0.0.5)
131
+ simplecov (0.22.0)
132
+ docile (~> 1.1)
133
+ simplecov-html (~> 0.11)
134
+ simplecov_json_formatter (~> 0.1)
135
+ simplecov-html (0.12.3)
136
+ simplecov_json_formatter (0.1.4)
76
137
  simpleidn (0.2.1)
77
138
  unf (~> 0.1.4)
139
+ tzinfo (2.0.6)
140
+ concurrent-ruby (~> 1.0)
78
141
  unf (0.1.4)
79
142
  unf_ext
80
143
  unf_ext (0.0.9.1)
81
144
  unicode-display_width (2.5.0)
145
+ webrick (1.8.1)
82
146
  zeitwerk (2.6.12)
83
147
 
84
148
  PLATFORMS
@@ -87,13 +151,16 @@ PLATFORMS
87
151
  x86_64-linux
88
152
 
89
153
  DEPENDENCIES
154
+ actionpack
90
155
  bundler
91
156
  openapi_first!
92
157
  rack (>= 3.0.0)
93
158
  rack-test
159
+ rackup
94
160
  rake
95
161
  rspec
96
162
  rubocop
163
+ simplecov
97
164
 
98
165
  BUNDLED WITH
99
166
  2.3.10
data/Gemfile.rack2.lock CHANGED
@@ -1,11 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (1.0.0.beta6)
4
+ openapi_first (1.1.1)
5
5
  json_refs (~> 0.1, >= 0.1.7)
6
6
  json_schemer (~> 2.1.0)
7
7
  multi_json (~> 1.15)
8
- mustermann-contrib (~> 3.0.0)
8
+ mustermann (~> 3.0.0)
9
9
  openapi_parameters (>= 0.3.2, < 2.0)
10
10
  rack (>= 2.2, < 4.0)
11
11
 
@@ -15,7 +15,6 @@ GEM
15
15
  ast (2.4.2)
16
16
  diff-lcs (1.5.0)
17
17
  hana (1.3.7)
18
- hansi (0.2.1)
19
18
  json (2.7.1)
20
19
  json_refs (0.1.8)
21
20
  hana
@@ -27,9 +26,6 @@ GEM
27
26
  multi_json (1.15.0)
28
27
  mustermann (3.0.0)
29
28
  ruby2_keywords (~> 0.0.1)
30
- mustermann-contrib (3.0.0)
31
- hansi (~> 0.2.0)
32
- mustermann (= 3.0.0)
33
29
  openapi_parameters (0.3.2)
34
30
  rack (>= 2.2)
35
31
  zeitwerk (~> 2.6)
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # openapi_first
2
2
 
3
- OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.openapis.org/) API description. It supports OpenAPI 3.0 and 3.1. It offers request and response validation to ensure that your implementation follows exactly the API description.
4
-
5
- This makes your API description reliable, reason about API design and use various tooling on top of OpenAPI.
3
+ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.openapis.org/) API description. It supports OpenAPI 3.0 and 3.1. It offers request and response validation and it ensures that your implementation follows exactly the API description.
6
4
 
7
5
  ## Contents
8
6
 
@@ -10,8 +8,12 @@ This makes your API description reliable, reason about API design and use variou
10
8
 
11
9
  - [Manual use](#manual-use)
12
10
  - [Rack Middlewares](#rack-middlewares)
11
+ - [Request validation](#request-validation)
12
+ - [Response validation](#response-validation)
13
13
  - [Configuration](#configuration)
14
14
  - [Development](#development)
15
+ - [Benchmarks](#benchmarks)
16
+ - [Contributing](#contributing)
15
17
 
16
18
  <!-- /TOC -->
17
19
 
@@ -30,17 +32,21 @@ Validate request / response:
30
32
  ```ruby
31
33
 
32
34
  # Find the request
33
- rack_request = Rack::Request.new(env)
35
+ rack_request = Rack::Request.new(env) # GET /pets/42
34
36
  request = definition.request(rack_request)
35
37
 
36
38
  # Inspect the request and access parsed parameters
37
39
  request.known? # Is the request defined in the API description?
38
- request.parsed_body # alias: body
39
- request.path_parameters
40
- request.query # alias: query_parameters
40
+ request.content_type
41
+ request.body # alias: parsed_body
42
+ request.path_parameters # => { "pet_id" => 42 }
43
+ request.query_parameters # alias: query
41
44
  request.params # Merged path and query parameters
42
45
  request.headers
43
46
  request.cookies
47
+ request.request_method # => "get"
48
+ request.path # => "/pets/42"
49
+ request.path_definition # => "/pets/{pet_id}"
44
50
 
45
51
  # Validate the request
46
52
  request.validate # Returns OpenapiFirst:::Failure if validation fails
@@ -50,8 +56,15 @@ request.validate! # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::No
50
56
  rack_response = Rack::Response[*app.call(env)]
51
57
  response = request.response(rack_response) # or definition.response(rack_request, rack_response)
52
58
 
59
+ # Inspect the response
60
+ response.known? # Is the response defined in the API description?
61
+ response.status # => 200
62
+ response.content_type
63
+ response.body
64
+ request.headers # parsed response headers
65
+
53
66
  # Validate response
54
- response.validate # Returns OpenapiFirst::Failure
67
+ response.validate # Returns OpenapiFirst::Failure if validation fails
55
68
  response.validate! # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError if validation fails
56
69
  ```
57
70
 
@@ -73,13 +86,13 @@ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
73
86
 
74
87
  #### Options
75
88
 
76
- | Name | Possible values | Description |
77
- | :---------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
78
- | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
79
- | `raise_error:` | `false` (default), `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` or `OpenapiFirst::NotFoundError` instead of returning 4xx. |
80
- | `error_response:` | `:default` (default), `:json_api`, Your implementation of `ErrorResponse` | :default |
89
+ | Name | Possible values | Description |
90
+ | :---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
92
+ | `raise_error:` | `false` (default), `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` or `OpenapiFirst::NotFoundError` instead of returning 4xx. |
93
+ | `error_response:` | `:default` (default), `:jsonapi`, Your implementation of `ErrorResponse` |
81
94
 
82
- Here's an example response body about an invalid request body. See also [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457).
95
+ Here in an example response body about an invalid request body. See also [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457).
83
96
 
84
97
  ```json
85
98
  http-status: 400
@@ -108,6 +121,50 @@ content-type: "application/problem+json"
108
121
  }
109
122
  ```
110
123
 
124
+ openapi_first offers a [JSON:API](https://jsonapi.org/) error response as well:
125
+
126
+ ```ruby
127
+ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_response: :jsonapi'
128
+ ```
129
+
130
+ Here is an example error response:
131
+
132
+ ```json
133
+ // http-status: 400
134
+ // content-type: "application/vnd.api+json"
135
+
136
+ {
137
+ "errors": [
138
+ {
139
+ "status": "400",
140
+ "source": {
141
+ "pointer": "/data/name"
142
+ },
143
+ "title": "value at `/data/name` is not a string",
144
+ "code": "string"
145
+ },
146
+ {
147
+ "status": "400",
148
+ "source": {
149
+ "pointer": "/data/numberOfLegs"
150
+ },
151
+ "title": "number at `/data/numberOfLegs` is less than: 2",
152
+ "code": "minimum"
153
+ },
154
+ {
155
+ "status": "400",
156
+ "source": {
157
+ "pointer": "/data"
158
+ },
159
+ "title": "object at `/data` is missing required properties: mandatory",
160
+ "code": "required"
161
+ }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
167
+
111
168
  #### readOnly / writeOnly properties
112
169
 
113
170
  Request validation fails if request includes a property with `readOnly: true`.
@@ -135,7 +192,7 @@ You can configure default options globally:
135
192
  ```ruby
136
193
  OpenapiFirst.configure do |config|
137
194
  # Specify which plugin is used to render error responses returned by the request validation middleware (defaults to :default)
138
- config.request_validation_error_response = :json_api
195
+ config.request_validation_error_response = :jsonapi
139
196
  # Configure if the response validation middleware should raise an exception (defaults to false)
140
197
  config.request_validation_raise_error = true
141
198
  end
@@ -4,7 +4,12 @@ require 'multi_json'
4
4
 
5
5
  module OpenapiFirst
6
6
  class BodyParser
7
- class ParsingError < StandardError; end
7
+ def self.const_missing(const_name)
8
+ super unless const_name == :ParsingError
9
+ warn 'DEPRECATION WARNING: OpenapiFirst::BodyParser::ParsingError is deprecated. ' \
10
+ 'Use OpenapiFirst::ParseError instead.'
11
+ OpenapiFirst::ParseError
12
+ end
8
13
 
9
14
  def parse(request, content_type)
10
15
  body = read_body(request)
@@ -15,7 +20,7 @@ module OpenapiFirst
15
20
 
16
21
  body
17
22
  rescue MultiJson::ParseError
18
- raise ParsingError, 'Failed to parse body as JSON'
23
+ raise ParseError, 'Failed to parse body as JSON'
19
24
  end
20
25
 
21
26
  private
@@ -2,11 +2,8 @@
2
2
 
3
3
  require 'forwardable'
4
4
  require 'set'
5
+ require 'openapi_parameters'
5
6
  require_relative 'request_body'
6
- require_relative 'query_parameters'
7
- require_relative 'header_parameters'
8
- require_relative 'path_parameters'
9
- require_relative 'cookie_parameters'
10
7
  require_relative 'responses'
11
8
 
12
9
  module OpenapiFirst
@@ -14,14 +11,11 @@ module OpenapiFirst
14
11
  class Operation
15
12
  extend Forwardable
16
13
  def_delegators :operation_object,
17
- :[],
18
- :dig
14
+ :[]
19
15
 
20
16
  WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
21
17
  private_constant :WRITE_METHODS
22
18
 
23
- attr_reader :path, :method, :openapi_version
24
-
25
19
  def initialize(path, request_method, path_item_object, openapi_version:)
26
20
  @path = path
27
21
  @method = request_method
@@ -30,6 +24,9 @@ module OpenapiFirst
30
24
  @operation_object = @path_item_object[request_method]
31
25
  end
32
26
 
27
+ attr_reader :path, :method, :openapi_version
28
+ alias request_method method
29
+
33
30
  def operation_id
34
31
  operation_object['operationId']
35
32
  end
@@ -66,34 +63,63 @@ module OpenapiFirst
66
63
  @name ||= "#{method.upcase} #{path} (#{operation_id})"
67
64
  end
68
65
 
69
- def query_parameters
70
- @query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
71
- end
72
-
73
66
  def path_parameters
74
- @path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
67
+ all_parameters['path']
75
68
  end
76
69
 
77
- IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
78
- private_constant :IGNORED_HEADERS
70
+ def query_parameters
71
+ all_parameters['query']
72
+ end
79
73
 
80
74
  def header_parameters
81
- @header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
75
+ all_parameters['header']
82
76
  end
83
77
 
84
78
  def cookie_parameters
85
- @cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
79
+ all_parameters['cookie']
80
+ end
81
+
82
+ def path_parameters_schema
83
+ @path_parameters_schema ||= build_schema(path_parameters)
84
+ end
85
+
86
+ def query_parameters_schema
87
+ @query_parameters_schema ||= build_schema(query_parameters)
88
+ end
89
+
90
+ def header_parameters_schema
91
+ @header_parameters_schema ||= build_schema(header_parameters)
92
+ end
93
+
94
+ def cookie_parameters_schema
95
+ @cookie_parameters_schema ||= build_schema(cookie_parameters)
86
96
  end
87
97
 
88
98
  private
89
99
 
100
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
101
+ private_constant :IGNORED_HEADERS
102
+
90
103
  def all_parameters
91
- @all_parameters ||= begin
92
- parameters = @path_item_object['parameters']&.dup || []
93
- parameters_on_operation = operation_object['parameters']
94
- parameters.concat(parameters_on_operation) if parameters_on_operation
95
- parameters
104
+ @all_parameters ||= (@path_item_object.fetch('parameters', []) + operation_object.fetch('parameters', []))
105
+ .reject { |p| p['in'] == 'header' && IGNORED_HEADERS.include?(p['name']) }
106
+ .group_by { _1['in'] }
107
+ end
108
+
109
+ def build_schema(parameters)
110
+ return unless parameters&.any?
111
+
112
+ init_schema = {
113
+ 'type' => 'object',
114
+ 'properties' => {},
115
+ 'required' => []
116
+ }
117
+ schema = parameters.each_with_object(init_schema) do |parameter_def, result|
118
+ parameter = OpenapiParameters::Parameter.new(parameter_def)
119
+ result['properties'][parameter.name] = parameter.schema if parameter.schema
120
+ result['required'] << parameter.name if parameter.required?
96
121
  end
122
+ Schema.new(schema, openapi_version: @openapi_version)
97
123
  end
98
124
 
99
125
  def responses
@@ -105,12 +131,6 @@ module OpenapiFirst
105
131
  def build_parameters(parameters, klass)
106
132
  klass.new(parameters, openapi_version:) if parameters.any?
107
133
  end
108
-
109
- def find_header_parameters
110
- all_parameters.filter do |p|
111
- p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name'])
112
- end
113
- end
114
134
  end
115
135
  end
116
136
  end
@@ -3,40 +3,42 @@
3
3
  require_relative '../schema'
4
4
 
5
5
  module OpenapiFirst
6
- class RequestBody
7
- def initialize(request_body_object, operation)
8
- @request_body_object = request_body_object
9
- @operation = operation
10
- end
6
+ class Definition
7
+ class RequestBody
8
+ def initialize(request_body_object, operation)
9
+ @request_body_object = request_body_object
10
+ @operation = operation
11
+ end
11
12
 
12
- def description
13
- @request_body_object['description']
14
- end
13
+ def description
14
+ @request_body_object['description']
15
+ end
15
16
 
16
- def required?
17
- !!@request_body_object['required']
18
- end
17
+ def required?
18
+ !!@request_body_object['required']
19
+ end
19
20
 
20
- def schema_for(content_type)
21
- content = @request_body_object['content']
22
- return unless content&.any?
21
+ def schema_for(content_type)
22
+ content = @request_body_object['content']
23
+ return unless content&.any?
23
24
 
24
- content_schemas&.fetch(content_type) do
25
- type = content_type.split(';')[0]
26
- content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
25
+ content_schemas&.fetch(content_type) do
26
+ type = content_type.split(';')[0]
27
+ content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
28
+ end
27
29
  end
28
- end
29
30
 
30
- private
31
+ private
31
32
 
32
- def content_schemas
33
- @content_schemas ||= @request_body_object['content']&.each_with_object({}) do |kv, result|
34
- type, media_type = kv
35
- schema_object = media_type['schema']
36
- next unless schema_object
33
+ def content_schemas
34
+ @content_schemas ||= @request_body_object['content']&.each_with_object({}) do |kv, result|
35
+ type, media_type = kv
36
+ schema_object = media_type['schema']
37
+ next unless schema_object
37
38
 
38
- result[type] = Schema.new(schema_object, write: @operation.write?,
39
- openapi_version: @operation.openapi_version)
39
+ result[type] = Schema.new(schema_object, write: @operation.write?,
40
+ openapi_version: @operation.openapi_version)
41
+ end
40
42
  end
41
43
  end
42
44
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'mustermann/template'
3
+ require 'mustermann'
4
4
  require_relative 'definition/path_item'
5
5
  require_relative 'runtime_request'
6
6
 
@@ -9,7 +9,7 @@ module OpenapiFirst
9
9
  class Definition
10
10
  attr_reader :filepath, :paths, :openapi_version
11
11
 
12
- def initialize(resolved, filepath)
12
+ def initialize(resolved, filepath = nil)
13
13
  @filepath = filepath
14
14
  @paths = resolved['paths']
15
15
  @openapi_version = detect_version(resolved)
@@ -60,7 +60,7 @@ module OpenapiFirst
60
60
 
61
61
  def search_for_path_item(request_path)
62
62
  paths.find do |path, path_item_object|
63
- template = Mustermann::Template.new(path)
63
+ template = Mustermann.new(path)
64
64
  path_params = template.params(request_path)
65
65
  next unless path_params
66
66
  next unless path_params.size == template.names.size
@@ -2,6 +2,7 @@
2
2
 
3
3
  module OpenapiFirst
4
4
  class Error < StandardError; end
5
+ class ParseError < Error; end
5
6
  class NotFoundError < Error; end
6
7
  class RequestInvalidError < Error; end
7
8
  class ResponseNotFoundError < Error; end
@@ -20,7 +20,7 @@ module OpenapiFirst
20
20
  private_constant :TYPES
21
21
 
22
22
  # @param error_type [Symbol] See Failure::TYPES.keys
23
- # @param errors [Array<OpenapiFirst::Schema::ValidationResult>]
23
+ # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
24
24
  def self.fail!(error_type, message: nil, errors: nil)
25
25
  throw FAILURE, new(
26
26
  error_type,
@@ -47,9 +47,22 @@ module OpenapiFirst
47
47
 
48
48
  # Raise an exception that fits the failure.
49
49
  def raise!
50
- exception, message_prefix = TYPES.fetch(error_type)
50
+ exception, = TYPES.fetch(error_type)
51
+ raise exception, exception_message
52
+ end
53
+
54
+ def exception_message
55
+ _, message_prefix = TYPES.fetch(error_type)
56
+
57
+ "#{message_prefix} #{@message || generate_message}"
58
+ end
59
+
60
+ private
51
61
 
52
- raise exception, "#{message_prefix} #{@message || errors&.map(&:error)&.join('. ')}"
62
+ def generate_message
63
+ messages = errors&.take(4)&.map(&:error)
64
+ messages << "... (#{errors.size} errors total)" if errors && errors.size > 4
65
+ messages&.join('. ')
53
66
  end
54
67
  end
55
68
  end
@@ -19,10 +19,13 @@ module OpenapiFirst
19
19
 
20
20
  def call(env)
21
21
  request = find_request(env)
22
- response = @app.call(env)
23
- request.response(response).validate!
22
+ status, headers, body = @app.call(env)
24
23
 
25
- response
24
+ body = body.to_ary if body.respond_to?(:to_ary)
25
+
26
+ request.response(Rack::Response[status, headers, body]).validate!
27
+
28
+ [status, headers, body]
26
29
  end
27
30
 
28
31
  private
@@ -16,7 +16,7 @@ module OpenapiFirst
16
16
  invalid_query: 'Bad Query Parameter',
17
17
  invalid_header: 'Bad Request Header',
18
18
  invalid_path: 'Bad Request Path',
19
- invalid_cookie: 'Bod Request Cookie'
19
+ invalid_cookie: 'Bad Request Cookie'
20
20
  }.freeze
21
21
  private_constant :TITLES
22
22
 
@@ -22,7 +22,8 @@ module OpenapiFirst
22
22
  {
23
23
  status: status.to_s,
24
24
  source: { key => pointer(error.instance_location) },
25
- title: error.error
25
+ title: error.error,
26
+ code: error.type
26
27
  }
27
28
  end
28
29
  end
@@ -38,34 +38,34 @@ module OpenapiFirst
38
38
  end
39
39
 
40
40
  def validate_path_params!(request)
41
- parameters = operation.path_parameters
42
- return unless parameters
41
+ schema = operation.path_parameters_schema
42
+ return unless schema
43
43
 
44
- validation = parameters.schema.validate(request.path_parameters)
44
+ validation = schema.validate(request.path_parameters)
45
45
  Failure.fail!(:invalid_path, errors: validation.errors) if validation.error?
46
46
  end
47
47
 
48
48
  def validate_query_params!(request)
49
- parameters = operation.query_parameters
50
- return unless parameters
49
+ schema = operation.query_parameters_schema
50
+ return unless schema
51
51
 
52
- validation = parameters.schema.validate(request.query)
52
+ validation = schema.validate(request.query)
53
53
  Failure.fail!(:invalid_query, errors: validation.errors) if validation.error?
54
54
  end
55
55
 
56
56
  def validate_cookie_params!(request)
57
- parameters = operation.cookie_parameters
58
- return unless parameters
57
+ schema = operation.cookie_parameters_schema
58
+ return unless schema
59
59
 
60
- validation = parameters.schema.validate(request.cookies)
60
+ validation = schema.validate(request.cookies)
61
61
  Failure.fail!(:invalid_cookie, errors: validation.errors) if validation.error?
62
62
  end
63
63
 
64
64
  def validate_header_params!(request)
65
- parameters = operation.header_parameters
66
- return unless parameters
65
+ schema = operation.header_parameters_schema
66
+ return unless schema
67
67
 
68
- validation = parameters.schema.validate(request.headers)
68
+ validation = schema.validate(request.headers)
69
69
  Failure.fail!(:invalid_header, errors: validation.errors) if validation.error?
70
70
  end
71
71
 
@@ -73,7 +73,7 @@ module OpenapiFirst
73
73
  return unless operation.request_body
74
74
 
75
75
  RequestBodyValidator.new(operation).validate!(request.body, request.content_type)
76
- rescue BodyParser::ParsingError => e
76
+ rescue ParseError => e
77
77
  Failure.fail!(:invalid_body, message: e.message)
78
78
  end
79
79
  end
@@ -9,14 +9,14 @@ module OpenapiFirst
9
9
  @operation = operation
10
10
  end
11
11
 
12
- def validate(rack_response)
12
+ def validate(runtime_response)
13
13
  return unless operation
14
14
 
15
- response = Rack::Response[*rack_response.to_a]
16
15
  catch Failure::FAILURE do
17
- response_definition = response_for(operation, response.status, response.content_type)
18
- validate_response_body(response_definition.content_schema, response.body)
19
- validate_response_headers(response_definition.headers, response.headers)
16
+ validate_defined(runtime_response)
17
+ response_definition = runtime_response.response_definition
18
+ validate_response_body(response_definition.content_schema, runtime_response)
19
+ validate_response_headers(response_definition.headers, runtime_response.headers)
20
20
  nil
21
21
  end
22
22
  end
@@ -25,37 +25,40 @@ module OpenapiFirst
25
25
 
26
26
  attr_reader :operation
27
27
 
28
- def response_for(operation, status, content_type)
29
- response = operation.response_for(status, content_type)
30
- return response if response
28
+ def validate_defined(runtime_response)
29
+ return if runtime_response.known?
31
30
 
32
- unless operation.response_status_defined?(status)
33
- message = "Response status '#{status}' not found for '#{operation.name}'"
31
+ unless runtime_response.known_status?
32
+ message = "Response status '#{runtime_response.status}' not found for '#{runtime_response.name}'"
34
33
  Failure.fail!(:response_not_found, message:)
35
34
  end
35
+
36
+ content_type = runtime_response.content_type
36
37
  if content_type.nil? || content_type.empty?
37
- message = "Content-Type for '#{operation.name}' must not be empty"
38
+ message = "Content-Type for '#{runtime_response.name}' must not be empty"
38
39
  Failure.fail!(:invalid_response_header, message:)
39
40
  end
40
41
 
41
- message = "Content-Type '#{content_type}' is not defined for '#{operation.name}'"
42
+ message = "Content-Type '#{content_type}' is not defined for '#{runtime_response.name}'"
42
43
  Failure.fail!(:invalid_response_header, message:)
43
44
  end
44
45
 
45
- def validate_response_body(schema, response)
46
+ def validate_response_body(schema, runtime_response)
46
47
  return unless schema
47
48
 
48
- full_body = +''
49
- response.each { |chunk| full_body << chunk }
50
- data = full_body.empty? ? {} : load_json(full_body)
51
- validation = schema.validate(data)
49
+ begin
50
+ parsed_body = runtime_response.body
51
+ rescue ParseError => e
52
+ Failure.fail!(:invalid_response_body, message: e.message)
53
+ end
54
+
55
+ validation = schema.validate(parsed_body)
52
56
  Failure.fail!(:invalid_response_body, errors: validation.errors) if validation.error?
53
57
  end
54
58
 
55
- def validate_response_headers(response_header_definitions, response_headers)
59
+ def validate_response_headers(response_header_definitions, unpacked_headers)
56
60
  return unless response_header_definitions
57
61
 
58
- unpacked_headers = unpack_response_headers(response_header_definitions, response_headers)
59
62
  response_header_definitions.each do |name, definition|
60
63
  next if name == 'Content-Type'
61
64
 
@@ -84,13 +87,6 @@ module OpenapiFirst
84
87
  errors: validation_result.errors)
85
88
  end
86
89
 
87
- def unpack_response_headers(response_header_definitions, response_headers)
88
- headers_as_parameters = response_header_definitions.map do |name, definition|
89
- definition.merge('name' => name, 'in' => 'header')
90
- end
91
- OpenapiParameters::Header.new(headers_as_parameters).unpack(response_headers)
92
- end
93
-
94
90
  def load_json(string)
95
91
  MultiJson.load(string)
96
92
  rescue MultiJson::ParseError
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require 'openapi_parameters'
4
5
  require_relative 'runtime_response'
5
6
  require_relative 'body_parser'
6
7
  require_relative 'request_validation/validator'
@@ -17,8 +18,11 @@ module OpenapiFirst
17
18
  @original_path_params = path_params
18
19
  end
19
20
 
20
- def_delegators :@request, :content_type, :media_type
21
- def_delegators :@operation, :operation_id
21
+ def_delegators :@request, :content_type, :media_type, :path
22
+ def_delegators :@operation, :operation_id, :request_method
23
+ def_delegator :@path_item, :path, :path_definition
24
+
25
+ attr_reader :path_item, :operation
22
26
 
23
27
  def known?
24
28
  known_path? && known_request_method?
@@ -38,25 +42,32 @@ module OpenapiFirst
38
42
  end
39
43
 
40
44
  def path_parameters
45
+ return {} unless operation.path_parameters
46
+
41
47
  @path_parameters ||=
42
- operation.path_parameters&.unpack(@original_path_params) || {}
48
+ OpenapiParameters::Path.new(operation.path_parameters).unpack(@original_path_params) || {}
43
49
  end
44
50
 
45
51
  def query
52
+ return {} unless operation.query_parameters
53
+
46
54
  @query ||=
47
- operation.query_parameters&.unpack(request.env) || {}
55
+ OpenapiParameters::Query.new(operation.query_parameters).unpack(request.env[Rack::QUERY_STRING]) || {}
48
56
  end
49
57
 
50
58
  alias query_parameters query
51
59
 
52
60
  def headers
53
- @headers ||=
54
- operation.header_parameters&.unpack(request.env) || {}
61
+ return {} unless operation.header_parameters
62
+
63
+ @headers ||= OpenapiParameters::Header.new(operation.header_parameters).unpack_env(request.env) || {}
55
64
  end
56
65
 
57
66
  def cookies
67
+ return {} unless operation.cookie_parameters
68
+
58
69
  @cookies ||=
59
- operation.cookie_parameters&.unpack(request.env) || {}
70
+ OpenapiParameters::Cookie.new(operation.cookie_parameters).unpack(request.env[Rack::HTTP_COOKIE]) || {}
60
71
  end
61
72
 
62
73
  def body
@@ -79,6 +90,6 @@ module OpenapiFirst
79
90
 
80
91
  private
81
92
 
82
- attr_reader :request, :operation, :path_item
93
+ attr_reader :request
83
94
  end
84
95
  end
@@ -1,20 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'forwardable'
4
+ require_relative 'body_parser'
3
5
  require_relative 'response_validation/validator'
4
6
 
5
7
  module OpenapiFirst
6
8
  class RuntimeResponse
9
+ extend Forwardable
10
+
7
11
  def initialize(operation, rack_response)
8
12
  @operation = operation
9
13
  @rack_response = rack_response
10
14
  end
11
15
 
16
+ def_delegators :@rack_response, :status, :content_type
17
+ def_delegators :@operation, :name
18
+
19
+ def known?
20
+ !!response_definition
21
+ end
22
+
23
+ def known_status?
24
+ @operation.response_status_defined?(status)
25
+ end
26
+
12
27
  def description
13
28
  response_definition&.description
14
29
  end
15
30
 
31
+ def body
32
+ @body ||= content_type =~ /json/i ? load_json(original_body) : original_body
33
+ end
34
+
35
+ def headers
36
+ @headers ||= unpack_response_headers
37
+ end
38
+
16
39
  def validate
17
- ResponseValidation::Validator.new(@operation).validate(@rack_response)
40
+ ResponseValidation::Validator.new(@operation).validate(self)
18
41
  end
19
42
 
20
43
  def validate!
@@ -22,10 +45,31 @@ module OpenapiFirst
22
45
  error&.raise!
23
46
  end
24
47
 
48
+ def response_definition
49
+ @response_definition ||= @operation.response_for(status, content_type)
50
+ end
51
+
25
52
  private
26
53
 
27
- def response_definition
28
- @response_definition ||= @operation.response_for(@rack_response.status, @rack_response.content_type)
54
+ def original_body
55
+ buffered_body = String.new
56
+ @rack_response.body.each { |chunk| buffered_body << chunk }
57
+ buffered_body
58
+ end
59
+
60
+ def load_json(string)
61
+ MultiJson.load(string)
62
+ rescue MultiJson::ParseError
63
+ raise ParseError, 'Failed to parse response body as JSON'
64
+ end
65
+
66
+ def unpack_response_headers
67
+ return {} if response_definition&.headers.nil?
68
+
69
+ headers_as_parameters = response_definition.headers.map do |name, definition|
70
+ definition.merge('name' => name, 'in' => 'header')
71
+ end
72
+ OpenapiParameters::Header.new(headers_as_parameters).unpack(@rack_response.headers)
29
73
  end
30
74
  end
31
75
  end
@@ -20,13 +20,6 @@ module OpenapiFirst
20
20
  ValidationError.new(err)
21
21
  end
22
22
  end
23
-
24
- # Returns a message that is used in exception messages.
25
- def message
26
- return unless error?
27
-
28
- errors.map(&:error).join('. ')
29
- end
30
23
  end
31
24
  end
32
25
  end
@@ -32,10 +32,6 @@ module OpenapiFirst
32
32
  )
33
33
  end
34
34
 
35
- def [](key)
36
- @schema[key]
37
- end
38
-
39
35
  private
40
36
 
41
37
  def before_property_validation(data, property, property_schema, parent)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.1'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -28,10 +28,20 @@ module OpenapiFirst
28
28
  # Key in rack to find instance of RuntimeRequest
29
29
  REQUEST = 'openapi.request'
30
30
 
31
- def self.load(spec_path, only: nil)
32
- resolved = Bundle.resolve(spec_path)
31
+ # Load and dereference an OpenAPI spec file
32
+ def self.load(filepath, only: nil)
33
+ resolved = bundle(filepath)
34
+ parse(resolved, only:, filepath:)
35
+ end
36
+
37
+ # Parse a dereferenced Hash
38
+ def self.parse(resolved, only: nil, filepath: nil)
33
39
  resolved['paths'].filter!(&->(key, _) { only.call(key) }) if only
34
- Definition.new(resolved, spec_path)
40
+ Definition.new(resolved, filepath)
41
+ end
42
+
43
+ def self.bundle(filepath)
44
+ Bundle.resolve(filepath)
35
45
  end
36
46
 
37
47
  module Bundle
@@ -41,7 +41,7 @@ Gem::Specification.new do |spec|
41
41
  spec.add_runtime_dependency 'json_refs', '~> 0.1', '>= 0.1.7'
42
42
  spec.add_runtime_dependency 'json_schemer', '~> 2.1.0'
43
43
  spec.add_runtime_dependency 'multi_json', '~> 1.15'
44
- spec.add_runtime_dependency 'mustermann-contrib', '~> 3.0.0'
44
+ spec.add_runtime_dependency 'mustermann', '~> 3.0.0'
45
45
  spec.add_runtime_dependency 'openapi_parameters', '>= 0.3.2', '< 2.0'
46
46
  spec.add_runtime_dependency 'rack', '>= 2.2', '< 4.0'
47
47
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-01-06 00:00:00.000000000 Z
11
+ date: 2024-01-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json_refs
@@ -59,7 +59,7 @@ dependencies:
59
59
  - !ruby/object:Gem::Version
60
60
  version: '1.15'
61
61
  - !ruby/object:Gem::Dependency
62
- name: mustermann-contrib
62
+ name: mustermann
63
63
  requirement: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
@@ -133,13 +133,8 @@ files:
133
133
  - lib/openapi_first/body_parser.rb
134
134
  - lib/openapi_first/configuration.rb
135
135
  - lib/openapi_first/definition.rb
136
- - lib/openapi_first/definition/cookie_parameters.rb
137
- - lib/openapi_first/definition/header_parameters.rb
138
136
  - lib/openapi_first/definition/operation.rb
139
- - lib/openapi_first/definition/parameters.rb
140
137
  - lib/openapi_first/definition/path_item.rb
141
- - lib/openapi_first/definition/path_parameters.rb
142
- - lib/openapi_first/definition/query_parameters.rb
143
138
  - lib/openapi_first/definition/request_body.rb
144
139
  - lib/openapi_first/definition/response.rb
145
140
  - lib/openapi_first/definition/responses.rb
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openapi_parameters'
4
- require_relative 'parameters'
5
-
6
- module OpenapiFirst
7
- class CookieParameters < Parameters
8
- def unpack(env)
9
- OpenapiParameters::Cookie.new(@parameter_definitions).unpack(env['HTTP_COOKIE'])
10
- end
11
- end
12
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openapi_parameters'
4
- require_relative 'parameters'
5
-
6
- module OpenapiFirst
7
- class HeaderParameters < Parameters
8
- def unpack(env)
9
- OpenapiParameters::Header.new(@parameter_definitions).unpack_env(env)
10
- end
11
- end
12
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'forwardable'
4
- require_relative '../schema'
5
-
6
- module OpenapiFirst
7
- class Parameters
8
- extend Forwardable
9
-
10
- def initialize(parameter_definitions, openapi_version:)
11
- @parameter_definitions = parameter_definitions
12
- @openapi_version = openapi_version
13
- end
14
-
15
- def_delegators :parameters, :map
16
-
17
- def empty?
18
- @parameter_definitions.empty?
19
- end
20
-
21
- def schema
22
- @schema ||= build_schema
23
- end
24
-
25
- def parameters
26
- @parameter_definitions.map do |parameter_object|
27
- OpenapiParameters::Parameter.new(parameter_object)
28
- end
29
- end
30
-
31
- private
32
-
33
- def build_schema
34
- init_schema = {
35
- 'type' => 'object',
36
- 'properties' => {},
37
- 'required' => []
38
- }
39
- schema = @parameter_definitions.each_with_object(init_schema) do |parameter_def, result|
40
- parameter = OpenapiParameters::Parameter.new(parameter_def)
41
- result['properties'][parameter.name] = parameter.schema if parameter.schema
42
- result['required'] << parameter.name if parameter.required?
43
- end
44
- Schema.new(schema, openapi_version: @openapi_version)
45
- end
46
- end
47
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openapi_parameters'
4
- require_relative 'parameters'
5
-
6
- module OpenapiFirst
7
- class PathParameters < Parameters
8
- def unpack(original_path_params)
9
- OpenapiParameters::Path.new(@parameter_definitions).unpack(original_path_params)
10
- end
11
- end
12
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openapi_parameters'
4
- require_relative 'parameters'
5
-
6
- module OpenapiFirst
7
- class QueryParameters < Parameters
8
- def unpack(env)
9
- OpenapiParameters::Query.new(@parameter_definitions).unpack(env['QUERY_STRING'])
10
- end
11
- end
12
- end