openapi_first 1.0.0.beta6 → 1.0.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -3
  3. data/Gemfile +1 -0
  4. data/Gemfile.lock +11 -10
  5. data/Gemfile.rack2.lock +99 -0
  6. data/README.md +99 -130
  7. data/lib/openapi_first/body_parser.rb +5 -4
  8. data/lib/openapi_first/configuration.rb +20 -0
  9. data/lib/openapi_first/definition/operation.rb +84 -71
  10. data/lib/openapi_first/definition/parameters.rb +1 -1
  11. data/lib/openapi_first/definition/path_item.rb +21 -12
  12. data/lib/openapi_first/definition/path_parameters.rb +2 -3
  13. data/lib/openapi_first/definition/request_body.rb +22 -11
  14. data/lib/openapi_first/definition/response.rb +19 -31
  15. data/lib/openapi_first/definition/responses.rb +83 -0
  16. data/lib/openapi_first/definition.rb +50 -17
  17. data/lib/openapi_first/error_response.rb +22 -29
  18. data/lib/openapi_first/errors.rb +2 -14
  19. data/lib/openapi_first/failure.rb +55 -0
  20. data/lib/openapi_first/middlewares/request_validation.rb +52 -0
  21. data/lib/openapi_first/middlewares/response_validation.rb +35 -0
  22. data/lib/openapi_first/plugins/default/error_response.rb +74 -0
  23. data/lib/openapi_first/plugins/default.rb +11 -0
  24. data/lib/openapi_first/plugins/jsonapi/error_response.rb +58 -0
  25. data/lib/openapi_first/plugins/jsonapi.rb +11 -0
  26. data/lib/openapi_first/plugins.rb +9 -7
  27. data/lib/openapi_first/request_validation/request_body_validator.rb +41 -0
  28. data/lib/openapi_first/request_validation/validator.rb +81 -0
  29. data/lib/openapi_first/response_validation/validator.rb +101 -0
  30. data/lib/openapi_first/runtime_request.rb +84 -0
  31. data/lib/openapi_first/runtime_response.rb +31 -0
  32. data/lib/openapi_first/schema/validation_error.rb +18 -0
  33. data/lib/openapi_first/schema/validation_result.rb +32 -0
  34. data/lib/openapi_first/{definition/schema.rb → schema.rb} +8 -4
  35. data/lib/openapi_first/version.rb +1 -1
  36. data/lib/openapi_first.rb +32 -28
  37. data/openapi_first.gemspec +3 -5
  38. metadata +28 -20
  39. data/lib/openapi_first/config.rb +0 -20
  40. data/lib/openapi_first/definition/has_content.rb +0 -37
  41. data/lib/openapi_first/definition/schema/result.rb +0 -17
  42. data/lib/openapi_first/error_responses/default.rb +0 -58
  43. data/lib/openapi_first/error_responses/json_api.rb +0 -58
  44. data/lib/openapi_first/request_body_validator.rb +0 -37
  45. data/lib/openapi_first/request_validation.rb +0 -122
  46. data/lib/openapi_first/request_validation_error.rb +0 -31
  47. data/lib/openapi_first/response_validation.rb +0 -113
  48. data/lib/openapi_first/response_validator.rb +0 -21
  49. data/lib/openapi_first/router.rb +0 -68
  50. data/lib/openapi_first/use_router.rb +0 -18
@@ -3,100 +3,113 @@
3
3
  require 'forwardable'
4
4
  require 'set'
5
5
  require_relative 'request_body'
6
- require_relative 'response'
7
6
  require_relative 'query_parameters'
8
7
  require_relative 'header_parameters'
9
8
  require_relative 'path_parameters'
10
9
  require_relative 'cookie_parameters'
11
- require_relative 'schema'
10
+ require_relative 'responses'
12
11
 
13
12
  module OpenapiFirst
14
- class Operation
15
- extend Forwardable
16
- def_delegators :operation_object,
17
- :[],
18
- :dig
19
-
20
- WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
21
- private_constant :WRITE_METHODS
22
-
23
- attr_reader :path, :method, :openapi_version
24
-
25
- def initialize(path, request_method, path_item_object, openapi_version:)
26
- @path = path
27
- @method = request_method
28
- @path_item_object = path_item_object
29
- @openapi_version = openapi_version
30
- @operation_object = @path_item_object[request_method]
31
- end
13
+ class Definition
14
+ class Operation
15
+ extend Forwardable
16
+ def_delegators :operation_object,
17
+ :[],
18
+ :dig
19
+
20
+ WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
21
+ private_constant :WRITE_METHODS
22
+
23
+ attr_reader :path, :method, :openapi_version
24
+
25
+ def initialize(path, request_method, path_item_object, openapi_version:)
26
+ @path = path
27
+ @method = request_method
28
+ @path_item_object = path_item_object
29
+ @openapi_version = openapi_version
30
+ @operation_object = @path_item_object[request_method]
31
+ end
32
32
 
33
- def operation_id
34
- operation_object['operationId']
35
- end
33
+ def operation_id
34
+ operation_object['operationId']
35
+ end
36
36
 
37
- def read?
38
- !write?
39
- end
37
+ def read?
38
+ !write?
39
+ end
40
40
 
41
- def write?
42
- WRITE_METHODS.include?(method)
43
- end
41
+ def write?
42
+ WRITE_METHODS.include?(method)
43
+ end
44
44
 
45
- def request_body
46
- @request_body ||= RequestBody.new(operation_object['requestBody'], self) if operation_object['requestBody']
47
- end
45
+ def request_body
46
+ @request_body ||= RequestBody.new(operation_object['requestBody'], self) if operation_object['requestBody']
47
+ end
48
48
 
49
- def response_for(status)
50
- response_object = operation_object.dig('responses', status.to_s) ||
51
- operation_object.dig('responses', "#{status / 100}XX") ||
52
- operation_object.dig('responses', "#{status / 100}xx") ||
53
- operation_object.dig('responses', 'default')
54
- Response.new(status, response_object, self) if response_object
55
- end
49
+ def response_status_defined?(status)
50
+ responses.status_defined?(status)
51
+ end
56
52
 
57
- def name
58
- @name ||= "#{method.upcase} #{path} (#{operation_id})"
59
- end
53
+ def_delegators :responses, :response_for
60
54
 
61
- def query_parameters
62
- @query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
63
- end
55
+ def schema_for(content_type)
56
+ content = @request_body_object['content']
57
+ return unless content&.any?
64
58
 
65
- def path_parameters
66
- @path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
67
- end
59
+ content_schemas&.fetch(content_type) do
60
+ type = content_type.split(';')[0]
61
+ content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
62
+ end
63
+ end
68
64
 
69
- IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
70
- private_constant :IGNORED_HEADERS
65
+ def name
66
+ @name ||= "#{method.upcase} #{path} (#{operation_id})"
67
+ end
71
68
 
72
- def header_parameters
73
- @header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
74
- end
69
+ def query_parameters
70
+ @query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
71
+ end
75
72
 
76
- def cookie_parameters
77
- @cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
78
- end
73
+ def path_parameters
74
+ @path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
75
+ end
76
+
77
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
78
+ private_constant :IGNORED_HEADERS
79
79
 
80
- def all_parameters
81
- @all_parameters ||= begin
82
- parameters = @path_item_object['parameters']&.dup || []
83
- parameters_on_operation = operation_object['parameters']
84
- parameters.concat(parameters_on_operation) if parameters_on_operation
85
- parameters
80
+ def header_parameters
81
+ @header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
86
82
  end
87
- end
88
83
 
89
- private
84
+ def cookie_parameters
85
+ @cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
86
+ end
90
87
 
91
- attr_reader :operation_object
88
+ private
92
89
 
93
- def build_parameters(parameters, klass)
94
- klass.new(parameters, openapi_version:) if parameters.any?
95
- end
90
+ 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
96
+ end
97
+ end
98
+
99
+ def responses
100
+ @responses ||= Responses.new(self, operation_object['responses'])
101
+ end
102
+
103
+ attr_reader :operation_object
104
+
105
+ def build_parameters(parameters, klass)
106
+ klass.new(parameters, openapi_version:) if parameters.any?
107
+ end
96
108
 
97
- def find_header_parameters
98
- all_parameters.filter do |p|
99
- p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name'])
109
+ def find_header_parameters
110
+ all_parameters.filter do |p|
111
+ p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name'])
112
+ end
100
113
  end
101
114
  end
102
115
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
- require_relative 'schema'
4
+ require_relative '../schema'
5
5
 
6
6
  module OpenapiFirst
7
7
  class Parameters
@@ -3,21 +3,30 @@
3
3
  require_relative 'operation'
4
4
 
5
5
  module OpenapiFirst
6
- class PathItem
7
- def initialize(path, path_item_object, openapi_version:)
8
- @path = path
9
- @path_item_object = path_item_object
10
- @openapi_version = openapi_version
11
- end
6
+ class Definition
7
+ class PathItem
8
+ def initialize(path, path_item_object, openapi_version:)
9
+ @path = path
10
+ @path_item_object = path_item_object
11
+ @openapi_version = openapi_version
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ def operation(request_method)
17
+ return unless @path_item_object[request_method]
12
18
 
13
- attr_reader :path
19
+ Operation.new(
20
+ @path, request_method, @path_item_object, openapi_version: @openapi_version
21
+ )
22
+ end
14
23
 
15
- def find_operation(request_method)
16
- return unless @path_item_object[request_method]
24
+ METHODS = %w[get head post put patch delete trace options].freeze
25
+ private_constant :METHODS
17
26
 
18
- Operation.new(
19
- @path, request_method, @path_item_object, openapi_version: @openapi_version
20
- )
27
+ def operations
28
+ @operations ||= @path_item_object.slice(*METHODS).keys.map { |method| operation(method) }
29
+ end
21
30
  end
22
31
  end
23
32
  end
@@ -2,12 +2,11 @@
2
2
 
3
3
  require 'openapi_parameters'
4
4
  require_relative 'parameters'
5
- require_relative '../router'
6
5
 
7
6
  module OpenapiFirst
8
7
  class PathParameters < Parameters
9
- def unpack(env)
10
- OpenapiParameters::Path.new(@parameter_definitions).unpack(env[Router::RAW_PATH_PARAMS])
8
+ def unpack(original_path_params)
9
+ OpenapiParameters::Path.new(@parameter_definitions).unpack(original_path_params)
11
10
  end
12
11
  end
13
12
  end
@@ -1,32 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'has_content'
3
+ require_relative '../schema'
4
4
 
5
5
  module OpenapiFirst
6
6
  class RequestBody
7
- include HasContent
8
-
9
7
  def initialize(request_body_object, operation)
10
- @object = request_body_object
8
+ @request_body_object = request_body_object
11
9
  @operation = operation
12
10
  end
13
11
 
14
12
  def description
15
- @object['description']
13
+ @request_body_object['description']
16
14
  end
17
15
 
18
16
  def required?
19
- !!@object['required']
17
+ !!@request_body_object['required']
20
18
  end
21
19
 
22
- private
20
+ def schema_for(content_type)
21
+ content = @request_body_object['content']
22
+ return unless content&.any?
23
23
 
24
- def schema_write?
25
- @operation.write?
24
+ content_schemas&.fetch(content_type) do
25
+ type = content_type.split(';')[0]
26
+ content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
27
+ end
26
28
  end
27
29
 
28
- def content
29
- @object['content']
30
+ private
31
+
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
37
+
38
+ result[type] = Schema.new(schema_object, write: @operation.write?,
39
+ openapi_version: @operation.openapi_version)
40
+ end
30
41
  end
31
42
  end
32
43
  end
@@ -1,37 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'has_content'
4
-
5
3
  module OpenapiFirst
6
- class Response
7
- include HasContent
8
-
9
- def initialize(status, response_object, operation)
10
- @status = status&.to_i
11
- @object = response_object
12
- @operation = operation
13
- end
14
-
15
- attr_reader :status
16
-
17
- def description
18
- @object['description']
19
- end
20
-
21
- def headers
22
- @object['headers']
23
- end
24
-
25
- def content?
26
- !!content&.any?
27
- end
28
-
29
- private
30
-
31
- def schema_write? = false
32
-
33
- def content
34
- @object['content']
4
+ class Definition
5
+ class Response
6
+ def initialize(operation:, status:, response_object:, content_type:, content_schema:)
7
+ @operation = operation
8
+ @response_object = response_object
9
+ @status = status
10
+ @content_type = content_type
11
+ @content_schema = content_schema
12
+ end
13
+
14
+ attr_reader :operation, :status, :content_type, :content_schema
15
+
16
+ def headers
17
+ @response_object['headers']
18
+ end
19
+
20
+ def description
21
+ @response_object['description']
22
+ end
35
23
  end
36
24
  end
37
25
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response'
4
+
5
+ module OpenapiFirst
6
+ class Definition
7
+ # @visibility private
8
+ class Responses
9
+ def initialize(operation, responses_object)
10
+ @operation = operation
11
+ @responses_object = responses_object
12
+ end
13
+
14
+ def status_defined?(status)
15
+ !!find_response_object(status)
16
+ end
17
+
18
+ def response_for(status, response_content_type)
19
+ response_object = find_response_object(status)
20
+ return unless response_object
21
+ return response_without_content(status, response_object) unless content_defined?(response_object)
22
+
23
+ defined_content_type = find_defined_content_type(response_object, response_content_type)
24
+ return unless defined_content_type
25
+
26
+ content_schema = find_content_schema(response_object, response_content_type)
27
+ Response.new(operation:, status:, response_object:, content_type: defined_content_type, content_schema:)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :openapi_version, :operation
33
+
34
+ def response_without_content(status, response_object)
35
+ Response.new(operation:, status:, response_object:, content_type: nil, content_schema: nil)
36
+ end
37
+
38
+ def find_defined_content_type(response_object, content_type)
39
+ return if content_type.nil?
40
+
41
+ content = response_object['content']
42
+ return content_type if content.key?(content_type)
43
+
44
+ type = content_type.split(';')[0]
45
+ return type if content.key?(type)
46
+
47
+ key = "#{type.split('/')[0]}/*"
48
+ return key if content.key?(key)
49
+
50
+ key = '*/*'
51
+ key if content.key?(key)
52
+ end
53
+
54
+ def content_defined?(response_object)
55
+ response_object['content']&.any?
56
+ end
57
+
58
+ def find_content_schema(response_object, response_content_type)
59
+ return unless response_content_type
60
+
61
+ content_object = find_response_body(response_object['content'], response_content_type)
62
+ content_schema_object = content_object&.fetch('schema', nil)
63
+ return unless content_schema_object
64
+
65
+ Schema.new(content_schema_object, write: false, openapi_version: operation.openapi_version)
66
+ end
67
+
68
+ def find_response_object(status)
69
+ @responses_object[status.to_s] ||
70
+ @responses_object["#{status / 100}XX"] ||
71
+ @responses_object["#{status / 100}xx"] ||
72
+ @responses_object['default']
73
+ end
74
+
75
+ def find_response_body(content, content_type)
76
+ content&.fetch(content_type) do |_|
77
+ type = content_type.split(';')[0]
78
+ content[type] || content["#{type.split('/')[0]}/*"] || content['*/*']
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'mustermann/template'
4
4
  require_relative 'definition/path_item'
5
+ require_relative 'runtime_request'
5
6
 
6
7
  module OpenapiFirst
7
8
  # Represents an OpenAPI API Description document
@@ -14,31 +15,63 @@ module OpenapiFirst
14
15
  @openapi_version = detect_version(resolved)
15
16
  end
16
17
 
17
- # @param request_path String
18
- def find_path_item_and_params(request_path)
19
- matches = paths.each_with_object([]) do |kv, result|
20
- path, path_item_object = kv
21
- template = Mustermann::Template.new(path)
22
- path_params = template.params(request_path)
23
- next unless path_params
18
+ def request(rack_request)
19
+ path_item, path_params = find_path_item_and_params(rack_request.path)
20
+ operation = path_item&.operation(rack_request.request_method.downcase)
21
+ RuntimeRequest.new(
22
+ request: rack_request,
23
+ path_item:,
24
+ operation:,
25
+ path_params:
26
+ )
27
+ end
24
28
 
25
- path_item = PathItem.new(path, path_item_object, openapi_version:)
26
- result << [path_item, path_params]
27
- end
28
- # Thanks to open ota42y/openapi_parser for this part
29
- matches.min_by { |match| match[1].size }
29
+ def response(rack_request, rack_response)
30
+ request(rack_request).response(rack_response)
30
31
  end
31
32
 
32
33
  def operations
33
- methods = %w[get head post put patch delete trace options]
34
- @operations ||= paths.flat_map do |path, path_item_object|
35
- path_item = PathItem.new(path, path_item_object, openapi_version:)
36
- path_item_object.slice(*methods).keys.map { |method| path_item.find_operation(method) }
37
- end
34
+ @operations ||= path_items.flat_map(&:operations)
35
+ end
36
+
37
+ def path(pathname)
38
+ return unless paths.key?(pathname)
39
+
40
+ PathItem.new(pathname, paths[pathname], openapi_version:)
38
41
  end
39
42
 
40
43
  private
41
44
 
45
+ def path_items
46
+ @path_items ||= paths.flat_map do |path, path_item_object|
47
+ PathItem.new(path, path_item_object, openapi_version:)
48
+ end
49
+ end
50
+
51
+ def find_path_item_and_params(request_path)
52
+ if paths.key?(request_path)
53
+ return [
54
+ PathItem.new(request_path, paths[request_path], openapi_version:),
55
+ {}
56
+ ]
57
+ end
58
+ search_for_path_item(request_path)
59
+ end
60
+
61
+ def search_for_path_item(request_path)
62
+ paths.find do |path, path_item_object|
63
+ template = Mustermann::Template.new(path)
64
+ path_params = template.params(request_path)
65
+ next unless path_params
66
+ next unless path_params.size == template.names.size
67
+
68
+ return [
69
+ PathItem.new(path, path_item_object, openapi_version:),
70
+ path_params
71
+ ]
72
+ end
73
+ end
74
+
42
75
  def detect_version(resolved)
43
76
  (resolved['openapi'] || resolved['swagger'])[0..2]
44
77
  end
@@ -1,47 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
4
-
5
3
  module OpenapiFirst
6
- # This is the base class for error responses
7
- class ErrorResponse
8
- ## @param request [Hash] The Rack request env
9
- ## @param request_validation_error [OpenapiFirst::RequestValidationError]
10
- def initialize(env, request_validation_error)
11
- @env = env
12
- @request_validation_error = request_validation_error
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
13
9
  end
14
10
 
15
- extend Forwardable
16
-
17
- attr_reader :env, :request_validation_error
18
-
19
- def_delegators :@request_validation_error, :status, :location, :schema_validation
11
+ attr_reader :failure
20
12
 
21
- def validation_output
22
- schema_validation&.output
13
+ # The response body
14
+ def body
15
+ raise NotImplementedError
23
16
  end
24
17
 
25
- def schema
26
- schema_validation&.schema
18
+ # The response content-type
19
+ def content_type
20
+ raise NotImplementedError
27
21
  end
28
22
 
29
- def data
30
- schema_validation&.data
31
- end
23
+ STATUS = {
24
+ not_found: 404,
25
+ method_not_allowed: 405,
26
+ unsupported_media_type: 415
27
+ }.freeze
28
+ private_constant :STATUS
32
29
 
33
- def message
34
- request_validation_error.message
30
+ # The response status
31
+ def status
32
+ STATUS[failure.error_type] || 400
35
33
  end
36
34
 
35
+ # Render this error response
37
36
  def render
38
37
  Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
39
38
  end
40
-
41
- def content_type = 'application/json'
42
-
43
- def body
44
- raise NotImplementedError
45
- end
46
39
  end
47
40
  end
@@ -2,20 +2,8 @@
2
2
 
3
3
  module OpenapiFirst
4
4
  class Error < StandardError; end
5
-
6
5
  class NotFoundError < Error; end
7
-
8
- class ResponseInvalid < Error; end
9
-
10
- class ResponseCodeNotFoundError < ResponseInvalid; end
11
-
12
- class ResponseContentTypeNotFoundError < ResponseInvalid; end
13
-
14
- class ResponseBodyInvalidError < ResponseInvalid; end
15
-
16
- class ResponseHeaderInvalidError < ResponseInvalid; end
17
-
18
- class BodyParsingError < Error; end
19
-
20
6
  class RequestInvalidError < Error; end
7
+ class ResponseNotFoundError < Error; end
8
+ class ResponseInvalidError < Error; end
21
9
  end