openapi_first 0.7.0.alpha1 → 3.1.0.beta1

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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +585 -2
  3. data/LICENSE.txt +21 -0
  4. data/README.md +360 -114
  5. data/lib/openapi_first/builder.rb +214 -0
  6. data/lib/openapi_first/child_configuration.rb +26 -0
  7. data/lib/openapi_first/configuration.rb +76 -0
  8. data/lib/openapi_first/definition.rb +98 -13
  9. data/lib/openapi_first/error_response.rb +40 -0
  10. data/lib/openapi_first/error_responses/default.rb +74 -0
  11. data/lib/openapi_first/error_responses/jsonapi.rb +60 -0
  12. data/lib/openapi_first/errors.rb +40 -0
  13. data/lib/openapi_first/failure.rb +77 -0
  14. data/lib/openapi_first/file_loader.rb +22 -0
  15. data/lib/openapi_first/header.rb +9 -0
  16. data/lib/openapi_first/json.rb +25 -0
  17. data/lib/openapi_first/middlewares/request_validation.rb +58 -0
  18. data/lib/openapi_first/middlewares/response_validation.rb +41 -0
  19. data/lib/openapi_first/parsed_request.rb +5 -0
  20. data/lib/openapi_first/ref_resolver.rb +193 -0
  21. data/lib/openapi_first/registry.rb +44 -0
  22. data/lib/openapi_first/request.rb +79 -0
  23. data/lib/openapi_first/request_body_parsers.rb +64 -0
  24. data/lib/openapi_first/request_validator.rb +32 -0
  25. data/lib/openapi_first/response.rb +43 -0
  26. data/lib/openapi_first/response_body_parsers.rb +31 -0
  27. data/lib/openapi_first/response_parser.rb +50 -0
  28. data/lib/openapi_first/response_validator.rb +16 -48
  29. data/lib/openapi_first/router/find_content.rb +17 -0
  30. data/lib/openapi_first/router/find_response.rb +53 -0
  31. data/lib/openapi_first/router/path_template.rb +48 -0
  32. data/lib/openapi_first/router.rb +84 -57
  33. data/lib/openapi_first/schema/hash.rb +39 -0
  34. data/lib/openapi_first/schema/validation_error.rb +45 -0
  35. data/lib/openapi_first/schema/validation_result.rb +30 -0
  36. data/lib/openapi_first/test/app.rb +30 -0
  37. data/lib/openapi_first/test/callable.rb +25 -0
  38. data/lib/openapi_first/test/configuration.rb +76 -0
  39. data/lib/openapi_first/test/coverage/covered_request.rb +11 -0
  40. data/lib/openapi_first/test/coverage/covered_response.rb +11 -0
  41. data/lib/openapi_first/test/coverage/plan.rb +77 -0
  42. data/lib/openapi_first/test/coverage/request_task.rb +42 -0
  43. data/lib/openapi_first/test/coverage/response_task.rb +42 -0
  44. data/lib/openapi_first/test/coverage/route_task.rb +13 -0
  45. data/lib/openapi_first/test/coverage/terminal_formatter.rb +115 -0
  46. data/lib/openapi_first/test/coverage/tracker.rb +32 -0
  47. data/lib/openapi_first/test/coverage.rb +102 -0
  48. data/lib/openapi_first/test/methods.rb +62 -0
  49. data/lib/openapi_first/test/minitest_helpers.rb +28 -0
  50. data/lib/openapi_first/test/observe.rb +37 -0
  51. data/lib/openapi_first/test/observer_middleware.rb +23 -0
  52. data/lib/openapi_first/test/plain_helpers.rb +31 -0
  53. data/lib/openapi_first/test.rb +171 -0
  54. data/lib/openapi_first/validated_request.rb +79 -0
  55. data/lib/openapi_first/validated_response.rb +53 -0
  56. data/lib/openapi_first/validators/request_body.rb +25 -0
  57. data/lib/openapi_first/validators/request_parameters.rb +49 -0
  58. data/lib/openapi_first/validators/response_body.rb +27 -0
  59. data/lib/openapi_first/validators/response_headers.rb +40 -0
  60. data/lib/openapi_first/version.rb +1 -1
  61. data/lib/openapi_first.rb +67 -35
  62. metadata +99 -133
  63. data/.github/CODEOWNERS +0 -1
  64. data/.gitignore +0 -11
  65. data/.rspec +0 -3
  66. data/.rubocop.yml +0 -14
  67. data/.travis.yml +0 -8
  68. data/Gemfile +0 -11
  69. data/Gemfile.lock +0 -126
  70. data/Rakefile +0 -15
  71. data/benchmarks/Gemfile +0 -13
  72. data/benchmarks/Gemfile.lock +0 -144
  73. data/benchmarks/apps/committee.ru +0 -30
  74. data/benchmarks/apps/grape.ru +0 -21
  75. data/benchmarks/apps/hanami_router.ru +0 -14
  76. data/benchmarks/apps/openapi.yaml +0 -86
  77. data/benchmarks/apps/openapi_first.ru +0 -22
  78. data/benchmarks/apps/openapi_first_resolve_only.ru +0 -23
  79. data/benchmarks/apps/sinatra.ru +0 -26
  80. data/benchmarks/apps/syro.ru +0 -25
  81. data/benchmarks/benchmarks.rb +0 -46
  82. data/bin/console +0 -15
  83. data/bin/setup +0 -8
  84. data/examples/README.md +0 -13
  85. data/examples/app.rb +0 -12
  86. data/examples/config.ru +0 -7
  87. data/examples/openapi.yaml +0 -29
  88. data/lib/openapi_first/app.rb +0 -27
  89. data/lib/openapi_first/coverage.rb +0 -28
  90. data/lib/openapi_first/operation.rb +0 -48
  91. data/lib/openapi_first/operation_resolver.rb +0 -43
  92. data/lib/openapi_first/request_validation.rb +0 -144
  93. data/lib/openapi_first/utils.rb +0 -31
  94. data/lib/openapi_first/validation.rb +0 -15
  95. data/lib/openapi_first/validation_format.rb +0 -31
  96. data/openapi_first.gemspec +0 -46
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ require_relative 'failure'
6
+ require_relative 'router'
7
+ require_relative 'header'
8
+ require_relative 'request'
9
+ require_relative 'response'
10
+ require_relative 'schema/hash'
11
+ require_relative 'ref_resolver'
12
+
13
+ module OpenapiFirst
14
+ # Builds parts of a Definition
15
+ # This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
16
+ class Builder # rubocop:disable Metrics/ClassLength
17
+ REQUEST_METHODS = %w[get head post put patch delete trace options query].freeze
18
+
19
+ # Builds a router from a resolved OpenAPI document.
20
+ # @param contents [Hash] The OpenAPI document Hash.
21
+ # @param config [OpenapiFirst::Configuration] The configuration object.
22
+ def self.build_router(contents, filepath:, config:)
23
+ new(contents, filepath:, config:).router
24
+ end
25
+
26
+ def initialize(contents, filepath:, config:)
27
+ meta_schema = detect_meta_schema(contents, filepath)
28
+ @schemer_configuration = build_schemer_config(filepath:, meta_schema:)
29
+ @config = config
30
+ @contents = RefResolver.for(contents, filepath:)
31
+ end
32
+
33
+ attr_reader :config
34
+ private attr_reader :schemer_configuration
35
+
36
+ def build_schemer_config(filepath:, meta_schema:)
37
+ result = JSONSchemer.configuration.clone
38
+ dir = (filepath && File.absolute_path(File.dirname(filepath))) || Dir.pwd
39
+ result.base_uri = URI::File.build({ path: "#{dir}/" })
40
+ result.ref_resolver = JSONSchemer::CachedResolver.new do |uri|
41
+ FileLoader.load(uri.path)
42
+ end
43
+ result.meta_schema = meta_schema
44
+ result.insert_property_defaults = true
45
+ result
46
+ end
47
+
48
+ def detect_meta_schema(document, filepath)
49
+ # Copied from JSONSchemer 🙇🏻‍♂️
50
+ version = document['openapi']
51
+ case version
52
+ when /\A3\.1\.\d+\z/
53
+ document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
54
+ when /\A3\.0\.\d+\z/
55
+ JSONSchemer::OpenAPI30::BASE_URI.to_s
56
+ else
57
+ raise Error, "Unsupported OpenAPI version #{version.inspect} #{filepath}"
58
+ end
59
+ end
60
+
61
+ def router # rubocop:disable Metrics/MethodLength
62
+ router = OpenapiFirst::Router.new
63
+ @contents.fetch('paths').each do |path, path_item_object|
64
+ path_parameters = path_item_object['parameters'] || []
65
+ path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
66
+ operation_object = path_item_object[request_method]
67
+ operation_parameters = operation_object['parameters'] || []
68
+ parameters = parse_parameters(operation_parameters.chain(path_parameters))
69
+
70
+ build_requests(path:, request_method:, operation_object:,
71
+ parameters:).each do |request|
72
+ router.add_request(
73
+ request,
74
+ request_method:,
75
+ path:,
76
+ content_type: request.content_type,
77
+ allow_empty_content: request.allow_empty_content?
78
+ )
79
+ build_responses(request:, responses: operation_object['responses']).each do |response|
80
+ router.add_response(
81
+ response,
82
+ request_method:,
83
+ path:,
84
+ status: response.status,
85
+ response_content_type: response.content_type
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
91
+ router
92
+ end
93
+
94
+ def parse_parameters(parameters)
95
+ grouped_parameters = group_parameters(parameters)
96
+ ParsedParameters.new(
97
+ query: resolve_parameters(grouped_parameters[:query]),
98
+ path: resolve_parameters(grouped_parameters[:path]),
99
+ cookie: resolve_parameters(grouped_parameters[:cookie]),
100
+ header: resolve_parameters(grouped_parameters[:header]),
101
+ query_schema: build_parameter_schema(grouped_parameters[:query]),
102
+ path_schema: build_parameter_schema(grouped_parameters[:path]),
103
+ cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
104
+ header_schema: build_parameter_schema(grouped_parameters[:header])
105
+ )
106
+ end
107
+
108
+ def resolve_parameters(parameters)
109
+ parameters&.map do |parameter|
110
+ result = parameter.resolved
111
+ result['schema'] = parameter['schema'].resolved
112
+ result
113
+ end.to_a
114
+ end
115
+
116
+ def build_parameter_schema(parameters)
117
+ return unless parameters
118
+
119
+ required = []
120
+ schemas = parameters.each_with_object({}) do |parameter, result|
121
+ schema = parameter['schema'].schema(configuration: schemer_configuration)
122
+ name = parameter['name']&.value
123
+ required << name if parameter['required']&.value
124
+ result[name] = schema if schema
125
+ end
126
+
127
+ Schema::Hash.new(schemas, required:, configuration: schemer_configuration,
128
+ after_property_validation: config.after_request_parameter_property_validation)
129
+ end
130
+
131
+ def build_requests(path:, request_method:, operation_object:, parameters:)
132
+ content_objects = operation_object.dig('requestBody', 'content')
133
+ if content_objects.nil?
134
+ return [
135
+ request_without_body(path:, request_method:, parameters:, operation_object:)
136
+ ]
137
+ end
138
+ required_body = operation_object['requestBody']&.resolved&.fetch('required', false) == true
139
+ content_objects.map do |content_type, content_object|
140
+ content_schema = content_object['schema'].schema(
141
+ configuration: schemer_configuration,
142
+ after_property_validation: config.after_request_body_property_validation
143
+ )
144
+ Request.new(path:, request_method:, parameters:,
145
+ operation_object: operation_object.resolved,
146
+ content_type:,
147
+ content_schema:,
148
+ required_body:,
149
+ key: [path, request_method, content_type].join(':'))
150
+ end
151
+ end
152
+
153
+ def request_without_body(path:, request_method:, parameters:, operation_object:)
154
+ Request.new(path:, request_method:, parameters:,
155
+ operation_object: operation_object.resolved,
156
+ content_type: nil,
157
+ content_schema: nil,
158
+ required_body: false,
159
+ key: [path, request_method, nil].join(':'))
160
+ end
161
+
162
+ def build_responses(responses:, request:)
163
+ return [] unless responses
164
+
165
+ responses.flat_map do |status, response_object|
166
+ headers = build_response_headers(response_object['headers'])
167
+ response_object['content']&.map do |content_type, content_object|
168
+ content_schema = content_object['schema'].schema(configuration: schemer_configuration)
169
+ Response.new(status:,
170
+ headers:,
171
+ content_type:,
172
+ content_schema:,
173
+ key: [request.key, status, content_type].join(':'))
174
+ end || Response.new(status:, headers:, content_type: nil,
175
+ content_schema: nil, key: [request.key, status, nil].join(':'))
176
+ end
177
+ end
178
+
179
+ IGNORED_HEADER_PARAMETERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
180
+ private_constant :IGNORED_HEADER_PARAMETERS
181
+
182
+ def build_response_headers(headers_object)
183
+ return if headers_object.nil?
184
+
185
+ result = []
186
+ headers_object.each do |name, header|
187
+ next if header['schema'].nil?
188
+ next if IGNORED_HEADER_PARAMETERS.include?(name)
189
+
190
+ header = Header.new(
191
+ name:,
192
+ schema: header['schema'].schema(configuration: schemer_configuration),
193
+ required?: header['required']&.value == true,
194
+ node: header
195
+ )
196
+ result << header
197
+ end
198
+ result
199
+ end
200
+
201
+ def group_parameters(parameter_definitions)
202
+ result = {}
203
+ parameter_definitions&.each do |parameter|
204
+ (result[parameter['in']&.value&.to_sym] ||= []) << parameter
205
+ end
206
+ result[:header]&.reject! { IGNORED_HEADER_PARAMETERS.include?(_1['name']&.value) }
207
+ result
208
+ end
209
+
210
+ ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
211
+ :cookie_schema)
212
+ private_constant :ParsedParameters
213
+ end
214
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # A subclass to configuration that points to its parent
5
+ class ChildConfiguration < Configuration
6
+ def initialize(parent:)
7
+ super()
8
+ @parent = parent
9
+ @request_validation_error_response = parent.request_validation_error_response
10
+ @request_validation_raise_error = parent.request_validation_raise_error
11
+ @response_validation_raise_error = parent.response_validation_raise_error
12
+ @path = parent.path
13
+ end
14
+
15
+ private attr_reader :parent
16
+
17
+ HOOKS.each do |hook|
18
+ define_method(hook) do |&block|
19
+ return hooks[hook].chain(parent.hooks[hook]) if block.nil?
20
+
21
+ hooks[hook] << block
22
+ block
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Global configuration. Currently only used for the request validation middleware.
5
+ class Configuration
6
+ HOOKS = %i[
7
+ after_request_validation
8
+ after_response_validation
9
+ after_request_parameter_property_validation
10
+ after_request_body_property_validation
11
+ ].freeze
12
+
13
+ def initialize
14
+ @request_validation_error_response = OpenapiFirst.find_error_response(:default)
15
+ @request_validation_raise_error = false
16
+ @response_validation_raise_error = true
17
+ @hooks = HOOKS.to_h { [_1, Set.new] }
18
+ @path = nil
19
+ end
20
+
21
+ def register(path_or_definition, as: :default)
22
+ OpenapiFirst.register(path_or_definition, as:)
23
+ end
24
+
25
+ attr_reader :hooks, :request_validation_error_response
26
+ attr_accessor :path
27
+
28
+ # @deprecated
29
+ attr_reader :request_validation_raise_error
30
+ # @deprecated
31
+ attr_reader :response_validation_raise_error
32
+
33
+ # Return a child configuration that still receives updates of global hooks.
34
+ def child
35
+ ChildConfiguration.new(parent: self)
36
+ end
37
+
38
+ # @visibility private
39
+ def clone
40
+ raise NoMethodError, 'OpenapiFirst::Configuration#clone was removed. You want to call #child instead'
41
+ end
42
+
43
+ # @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::RequestValidation directly
44
+ def request_validation_raise_error=(value)
45
+ message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
46
+ 'Please pass `raise_error:` to `OpenapiFirst::Middlewares::RequestValidation directly`'
47
+ warn message, category: :deprecated
48
+ @request_validation_raise_error = value
49
+ end
50
+
51
+ # @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::ResponseValidation directly
52
+ def response_validation_raise_error=(value)
53
+ message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
54
+ 'Please pass `raise_error:` to `OpenapiFirst::Middlewares::ResponseValidation directly`'
55
+ warn message
56
+ @response_validation_raise_error = value
57
+ end
58
+
59
+ HOOKS.each do |hook|
60
+ define_method(hook) do |&block|
61
+ return hooks[hook] if block.nil?
62
+
63
+ hooks[hook] << block
64
+ block
65
+ end
66
+ end
67
+
68
+ def request_validation_error_response=(mod)
69
+ @request_validation_error_response = if mod.is_a?(Symbol)
70
+ OpenapiFirst.find_error_response(mod)
71
+ else
72
+ mod
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,27 +1,112 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'operation'
3
+ require_relative 'builder'
4
+ require 'forwardable'
4
5
 
5
6
  module OpenapiFirst
7
+ # Represents an OpenAPI API Description document
8
+ # This is returned by OpenapiFirst.load.
6
9
  class Definition
7
- def initialize(parsed)
8
- @spec = parsed
10
+ extend Forwardable
11
+
12
+ # @return [String,nil]
13
+ attr_reader :filepath
14
+ # @return [Configuration]
15
+ attr_reader :config
16
+ # @return [Enumerable[String]]
17
+ attr_reader :paths
18
+ # @return [Router]
19
+ attr_reader :router
20
+
21
+ # @param contents [Hash] The OpenAPI document.
22
+ # @param filepath [String] The file path of the OpenAPI document.
23
+ def initialize(contents, filepath = nil)
24
+ @filepath = filepath
25
+ @config = OpenapiFirst.configuration.child
26
+ yield @config if block_given?
27
+ @config.freeze
28
+ @router = Builder.build_router(contents, filepath:, config:)
29
+ @resolved = contents
30
+ @paths = @router.routes.map(&:path).to_a.uniq # TODO: Refactor
9
31
  end
10
32
 
11
- def operations
12
- @spec.endpoints.map { |e| Operation.new(e) }
33
+ # Gives access to the raw resolved Hash. Like `mydefinition['components'].dig('schemas', 'Stations')`
34
+ # @!method [](key)
35
+ # @return [Hash]
36
+ def_delegators :@resolved, :[]
37
+
38
+ # Returns an Enumerable of available Routes for this API description.
39
+ # @!method routes
40
+ # @return [Enumerable[Router::Route]]
41
+ def_delegators :@router, :routes
42
+
43
+ # Returns a unique identifier for this API definition
44
+ # @return [String] A unique key for this API definition
45
+ def key
46
+ return filepath if filepath
47
+
48
+ info = self['info'] || {}
49
+ title = info['title']
50
+ version = info['version']
51
+
52
+ if title.nil? || version.nil?
53
+ raise ArgumentError,
54
+ "Cannot generate key for the OpenAPI document because 'info.title' or 'info.version' is missing. " \
55
+ 'Please add these fields to your OpenAPI document.'
56
+ end
57
+
58
+ "#{title} @ #{version}"
13
59
  end
14
60
 
15
- def find_operation!(request)
16
- @spec
17
- .path_by_path(request.path)
18
- .endpoint_by_method(request.request_method.downcase)
61
+ def inspect
62
+ "#<#{self.class.name} @key='#{key}'>"
19
63
  end
20
64
 
21
- def find_operation(request)
22
- find_operation!(request)
23
- rescue OasParser::PathNotFound, OasParser::MethodNotFound
24
- nil
65
+ # Validates the request against the API description.
66
+ # @param [Rack::Request] request The Rack request object.
67
+ # @param [Boolean] raise_error Whether to raise an error if validation fails.
68
+ # @return [ValidatedRequest] The validated request object.
69
+ def validate_request(request, raise_error: false)
70
+ route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
71
+ if route.error
72
+ ValidatedRequest.new(request, error: route.error)
73
+ else
74
+ route.request_definition.validate(request, route_params: route.params)
75
+ end.tap do |validated|
76
+ @config.after_request_validation.each { |hook| hook.call(validated, self) }
77
+ raise validated.error.exception(validated) if validated.error && raise_error
78
+ end
79
+ end
80
+
81
+ # Validates the response against the API description.
82
+ # @param rack_request [Rack::Request] The Rack request object.
83
+ # @param rack_response [Rack::Response] The Rack response object.
84
+ # @param raise_error [Boolean] Whethir to raise an error if validation fails.
85
+ # @return [ValidatedResponse] The validated response object.
86
+ def validate_response(rack_request, rack_response, raise_error: false)
87
+ route = @router.match(rack_request.request_method, resolve_path(rack_request),
88
+ content_type: rack_request.content_type)
89
+ return if route.error # Skip response validation for unknown requests
90
+
91
+ response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
92
+ error = response_match.error
93
+ validated = if error
94
+ ValidatedResponse.new(rack_response, error:)
95
+ else
96
+ response_match.response.validate(rack_response)
97
+ end
98
+ @config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) }
99
+ raise validated.error.exception(validated) if raise_error && validated.invalid?
100
+
101
+ validated
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_path(rack_request)
107
+ return rack_request.path unless @config.path
108
+
109
+ @config.path.call(rack_request)
25
110
  end
26
111
  end
27
112
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # This is the base module for error responses
5
+ module ErrorResponse
6
+ ## @param failure [OpenapiFirst::Failure]
7
+ def initialize(failure: nil)
8
+ @failure = failure
9
+ end
10
+
11
+ attr_reader :failure
12
+
13
+ # The response body
14
+ def body
15
+ raise "#{self.class} must implement the method #{__method__}"
16
+ end
17
+
18
+ # The response content-type
19
+ def content_type
20
+ raise "#{self.class} must implement the method #{__method__}"
21
+ end
22
+
23
+ STATUS = {
24
+ not_found: 404,
25
+ method_not_allowed: 405,
26
+ unsupported_media_type: 415
27
+ }.freeze
28
+ private_constant :STATUS
29
+
30
+ # The response status
31
+ def status
32
+ STATUS[failure.type] || 400
33
+ end
34
+
35
+ # Render this error response
36
+ def render
37
+ Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ # An error reponse that returns application/problem+json with a list of "errors"
6
+ # See also https://www.rfc-editor.org/rfc/rfc9457.html
7
+ class Default
8
+ include OpenapiFirst::ErrorResponse
9
+
10
+ OpenapiFirst.register_error_response(:default, self)
11
+
12
+ TITLES = {
13
+ not_found: 'Not Found',
14
+ method_not_allowed: 'Request Method Not Allowed',
15
+ unsupported_media_type: 'Unsupported Media Type',
16
+ invalid_body: 'Bad Request Body',
17
+ invalid_query: 'Bad Query Parameter',
18
+ invalid_header: 'Bad Request Header',
19
+ invalid_path: 'Bad Request Path',
20
+ invalid_cookie: 'Bad Request Cookie'
21
+ }.freeze
22
+ private_constant :TITLES
23
+
24
+ def body
25
+ result = {
26
+ title:,
27
+ status:
28
+ }
29
+ result[:errors] = errors if failure.errors
30
+ JSON.generate(result)
31
+ end
32
+
33
+ def type = failure.type
34
+
35
+ def title
36
+ TITLES.fetch(type)
37
+ end
38
+
39
+ def content_type
40
+ 'application/problem+json'
41
+ end
42
+
43
+ def errors
44
+ key = pointer_key
45
+ failure.errors.map do |error|
46
+ {
47
+ message: error.message,
48
+ key => pointer(error.data_pointer),
49
+ code: error.type
50
+ }
51
+ end
52
+ end
53
+
54
+ def pointer_key
55
+ case type
56
+ when :invalid_body
57
+ :pointer
58
+ when :invalid_query, :invalid_path
59
+ :parameter
60
+ when :invalid_header
61
+ :header
62
+ when :invalid_cookie
63
+ :cookie
64
+ end
65
+ end
66
+
67
+ def pointer(data_pointer)
68
+ return data_pointer if type == :invalid_body
69
+
70
+ data_pointer.delete_prefix('/')
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ # A JSON:API conform error response. See https://jsonapi.org/.
6
+ class Jsonapi
7
+ include OpenapiFirst::ErrorResponse
8
+
9
+ OpenapiFirst.register_error_response(:jsonapi, self)
10
+
11
+ def body
12
+ JSON.generate({ errors: serialized_errors })
13
+ end
14
+
15
+ def content_type
16
+ 'application/vnd.api+json'
17
+ end
18
+
19
+ def serialized_errors
20
+ return default_errors unless failure.errors
21
+
22
+ key = pointer_key
23
+ failure.errors.map do |error|
24
+ {
25
+ status: status.to_s,
26
+ source: { key => pointer(error.data_pointer) },
27
+ title: error.message,
28
+ code: error.type
29
+ }
30
+ end
31
+ end
32
+
33
+ def default_errors
34
+ [{
35
+ status: status.to_s,
36
+ title: Rack::Utils::HTTP_STATUS_CODES[status]
37
+ }]
38
+ end
39
+
40
+ def pointer_key
41
+ case failure.type
42
+ when :invalid_body
43
+ :pointer
44
+ when :invalid_query, :invalid_path
45
+ :parameter
46
+ when :invalid_header
47
+ :header
48
+ when :invalid_cookie
49
+ :cookie
50
+ end
51
+ end
52
+
53
+ def pointer(data_pointer)
54
+ return data_pointer if failure.type == :invalid_body
55
+
56
+ data_pointer.delete_prefix('/')
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Base class for all errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised if YAML/JSON file was not found
8
+ class FileNotFoundError < Error; end
9
+
10
+ # Raised if response body could not be parsed
11
+ class ParseError < Error; end
12
+
13
+ # Raised during request validation if request was invalid
14
+ class RequestInvalidError < Error
15
+ def initialize(message, validated_request)
16
+ super(message)
17
+ @request = validated_request
18
+ end
19
+
20
+ # @return [ValidatedRequest] The validated request
21
+ attr_reader :request
22
+ end
23
+
24
+ # Raised during request validation if request was not defined in the API description
25
+ class NotFoundError < RequestInvalidError; end
26
+
27
+ # Raised during response validation if request was invalid
28
+ class ResponseInvalidError < Error
29
+ def initialize(message, validated_response)
30
+ super(message)
31
+ @response = validated_response
32
+ end
33
+
34
+ # @return [ValidatedResponse] The validated response
35
+ attr_reader :response
36
+ end
37
+
38
+ # Raised during request validation if response was not defined in the API description
39
+ class ResponseNotFoundError < ResponseInvalidError; end
40
+ end