openapi_first 1.0.0 → 1.2.1

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 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