openapi_first 1.0.0.beta6 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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