openapi_first 1.0.0 → 1.1.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: 4bb6d0ee4dbe98020ef124e79a99ced0d0111b1e2891cc8cb3ed8bb697973d2b
4
+ data.tar.gz: 77ce896a68f885614f64fd680af0dde1860c07ae772ce7d5f7d1b75b5abb4528
5
5
  SHA512:
6
- metadata.gz: ed09d09b5ad4c131134843ed0c7772eb6742b9bd98e6bb410d2c7fca3efe9a49875993b095a9590ee1a5ec9484301d5a58ed67b445fdd46aacbabedaf7e8160e
7
- data.tar.gz: 9fe5c08c823aff23d979b9cd0652e4a63a8df424f98c037cb77e45c28c7fd232f24305df8024495b7bd50f4b99fbb8669d0f8a962b20ef2652907298ac7dd6cf
6
+ metadata.gz: 841e32068646969d92ab26fff9c332e849056463c99805c8e0dbbb437918113a09750fb5ceffd3940716911ea50bc20685acbcd862aa6acc270e43fe43675c87
7
+ data.tar.gz: d9a225b3e3119512fc3ed585f00f6f915e21f3199206f520d20733999441567e651fcb129588092c4e7ea9dcc4ce1e77c24365b04b8a8d9d232b8a1a5cd8e1da
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1
4
+
5
+ - Fix reading response body for example when running Rails (`ActionDispatch::Response::RackBody`)
6
+
7
+ ## 1.1.0
8
+
9
+ - Add `known?`, `status`, `body`, `headers`, `content_type` methods to inspect the parsed response (`RuntimeResponse`)
10
+ - Add `OpenapiFirst::ParseError` which is raised by low-level interfaces like `request.body` if the body could not be parsed.
11
+ - Add "code" field to errors in JSON:API error response
12
+
3
13
  ## 1.0.0
4
14
 
5
15
  - 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,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (1.0.0)
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)
@@ -12,10 +12,48 @@ PATH
12
12
  GEM
13
13
  remote: https://rubygems.org/
14
14
  specs:
15
+ actionpack (7.1.2)
16
+ actionview (= 7.1.2)
17
+ activesupport (= 7.1.2)
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.2)
26
+ activesupport (= 7.1.2)
27
+ builder (~> 3.1)
28
+ erubi (~> 1.11)
29
+ rails-dom-testing (~> 2.2)
30
+ rails-html-sanitizer (~> 1.6)
31
+ activesupport (7.1.2)
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.5)
44
+ builder (3.2.4)
45
+ concurrent-ruby (1.2.2)
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
54
  hansi (0.2.1)
55
+ i18n (1.14.1)
56
+ concurrent-ruby (~> 1.0)
19
57
  json (2.7.1)
20
58
  json_refs (0.1.8)
21
59
  hana
@@ -24,26 +62,47 @@ GEM
24
62
  regexp_parser (~> 2.0)
25
63
  simpleidn (~> 0.2)
26
64
  language_server-protocol (3.17.0.3)
65
+ loofah (2.22.0)
66
+ crass (~> 1.0.2)
67
+ nokogiri (>= 1.12.0)
68
+ minitest (5.21.1)
27
69
  multi_json (1.15.0)
28
70
  mustermann (3.0.0)
29
71
  ruby2_keywords (~> 0.0.1)
30
72
  mustermann-contrib (3.0.0)
31
73
  hansi (~> 0.2.0)
32
74
  mustermann (= 3.0.0)
75
+ mutex_m (0.2.0)
76
+ nokogiri (1.16.0-arm64-darwin)
77
+ racc (~> 1.4)
78
+ nokogiri (1.16.0-x86_64-linux)
79
+ racc (~> 1.4)
33
80
  openapi_parameters (0.3.2)
34
81
  rack (>= 2.2)
35
82
  zeitwerk (~> 2.6)
36
83
  parallel (1.24.0)
37
- parser (3.3.0.0)
84
+ parser (3.3.0.3)
38
85
  ast (~> 2.4.1)
39
86
  racc
40
87
  racc (1.7.3)
41
88
  rack (3.0.8)
89
+ rack-session (2.0.0)
90
+ rack (>= 3.0.0)
42
91
  rack-test (2.1.0)
43
92
  rack (>= 1.3)
93
+ rackup (2.1.0)
94
+ rack (>= 3)
95
+ webrick (~> 1.8)
96
+ rails-dom-testing (2.2.0)
97
+ activesupport (>= 5.0.0)
98
+ minitest
99
+ nokogiri (>= 1.6)
100
+ rails-html-sanitizer (1.6.0)
101
+ loofah (~> 2.21)
102
+ nokogiri (~> 1.14)
44
103
  rainbow (3.1.1)
45
104
  rake (13.1.0)
46
- regexp_parser (2.8.3)
105
+ regexp_parser (2.9.0)
47
106
  rexml (3.2.6)
48
107
  rspec (3.12.0)
49
108
  rspec-core (~> 3.12.0)
@@ -73,12 +132,21 @@ GEM
73
132
  parser (>= 3.2.1.0)
74
133
  ruby-progressbar (1.13.0)
75
134
  ruby2_keywords (0.0.5)
135
+ simplecov (0.22.0)
136
+ docile (~> 1.1)
137
+ simplecov-html (~> 0.11)
138
+ simplecov_json_formatter (~> 0.1)
139
+ simplecov-html (0.12.3)
140
+ simplecov_json_formatter (0.1.4)
76
141
  simpleidn (0.2.1)
77
142
  unf (~> 0.1.4)
143
+ tzinfo (2.0.6)
144
+ concurrent-ruby (~> 1.0)
78
145
  unf (0.1.4)
79
146
  unf_ext
80
147
  unf_ext (0.0.9.1)
81
148
  unicode-display_width (2.5.0)
149
+ webrick (1.8.1)
82
150
  zeitwerk (2.6.12)
83
151
 
84
152
  PLATFORMS
@@ -87,13 +155,16 @@ PLATFORMS
87
155
  x86_64-linux
88
156
 
89
157
  DEPENDENCIES
158
+ actionpack
90
159
  bundler
91
160
  openapi_first!
92
161
  rack (>= 3.0.0)
93
162
  rack-test
163
+ rackup
94
164
  rake
95
165
  rspec
96
166
  rubocop
167
+ simplecov
97
168
 
98
169
  BUNDLED WITH
99
170
  2.3.10
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,11 @@ 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. Use OpenapiFirst::ParseError instead.' # rubocop:disable Layout/LineLength
10
+ OpenapiFirst::ParseError
11
+ end
8
12
 
9
13
  def parse(request, content_type)
10
14
  body = read_body(request)
@@ -15,7 +19,7 @@ module OpenapiFirst
15
19
 
16
20
  body
17
21
  rescue MultiJson::ParseError
18
- raise ParsingError, 'Failed to parse body as JSON'
22
+ raise ParseError, 'Failed to parse body as JSON'
19
23
  end
20
24
 
21
25
  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
@@ -20,8 +17,6 @@ module OpenapiFirst
20
17
  WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
21
18
  private_constant :WRITE_METHODS
22
19
 
23
- attr_reader :path, :method, :openapi_version
24
-
25
20
  def initialize(path, request_method, path_item_object, openapi_version:)
26
21
  @path = path
27
22
  @method = request_method
@@ -30,6 +25,9 @@ module OpenapiFirst
30
25
  @operation_object = @path_item_object[request_method]
31
26
  end
32
27
 
28
+ attr_reader :path, :method, :openapi_version
29
+ alias request_method method
30
+
33
31
  def operation_id
34
32
  operation_object['operationId']
35
33
  end
@@ -66,34 +64,63 @@ module OpenapiFirst
66
64
  @name ||= "#{method.upcase} #{path} (#{operation_id})"
67
65
  end
68
66
 
69
- def query_parameters
70
- @query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
71
- end
72
-
73
67
  def path_parameters
74
- @path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
68
+ all_parameters['path']
75
69
  end
76
70
 
77
- IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
78
- private_constant :IGNORED_HEADERS
71
+ def query_parameters
72
+ all_parameters['query']
73
+ end
79
74
 
80
75
  def header_parameters
81
- @header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
76
+ all_parameters['header']
82
77
  end
83
78
 
84
79
  def cookie_parameters
85
- @cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
80
+ all_parameters['cookie']
81
+ end
82
+
83
+ def path_parameters_schema
84
+ @path_parameters_schema ||= build_schema(path_parameters)
85
+ end
86
+
87
+ def query_parameters_schema
88
+ @query_parameters_schema ||= build_schema(query_parameters)
89
+ end
90
+
91
+ def header_parameters_schema
92
+ @header_parameters_schema ||= build_schema(header_parameters)
93
+ end
94
+
95
+ def cookie_parameters_schema
96
+ @cookie_parameters_schema ||= build_schema(cookie_parameters)
86
97
  end
87
98
 
88
99
  private
89
100
 
101
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
102
+ private_constant :IGNORED_HEADERS
103
+
90
104
  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
105
+ @all_parameters ||= (@path_item_object.fetch('parameters', []) + operation_object.fetch('parameters', []))
106
+ .reject { |p| p['in'] == 'header' && IGNORED_HEADERS.include?(p['name']) }
107
+ .group_by { _1['in'] }
108
+ end
109
+
110
+ def build_schema(parameters)
111
+ return unless parameters&.any?
112
+
113
+ init_schema = {
114
+ 'type' => 'object',
115
+ 'properties' => {},
116
+ 'required' => []
117
+ }
118
+ schema = parameters.each_with_object(init_schema) do |parameter_def, result|
119
+ parameter = OpenapiParameters::Parameter.new(parameter_def)
120
+ result['properties'][parameter.name] = parameter.schema if parameter.schema
121
+ result['required'] << parameter.name if parameter.required?
96
122
  end
123
+ Schema.new(schema, openapi_version: @openapi_version)
97
124
  end
98
125
 
99
126
  def responses
@@ -105,12 +132,6 @@ module OpenapiFirst
105
132
  def build_parameters(parameters, klass)
106
133
  klass.new(parameters, openapi_version:) if parameters.any?
107
134
  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
135
  end
115
136
  end
116
137
  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
@@ -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,
@@ -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
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, :operation
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
@@ -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.1.1'
5
5
  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.1.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-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json_refs
@@ -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