openapi_first 0.10.2 → 0.12.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.
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- openapi_first (0.10.2)
4
+ openapi_first (0.12.0)
5
5
  deep_merge (>= 1.2.1)
6
- hanami-router (~> 2.0.alpha2)
6
+ hanami-router (~> 2.0.alpha3)
7
7
  hanami-utils (~> 2.0.alpha1)
8
8
  json_schemer (~> 0.2)
9
9
  multi_json (~> 1.14)
@@ -13,7 +13,7 @@ PATH
13
13
  GEM
14
14
  remote: https://rubygems.org/
15
15
  specs:
16
- activesupport (6.0.3)
16
+ activesupport (6.0.3.1)
17
17
  concurrent-ruby (~> 1.0, >= 1.0.2)
18
18
  i18n (>= 0.7, < 2)
19
19
  minitest (~> 5.1)
@@ -25,9 +25,9 @@ GEM
25
25
  benchmark-memory (0.1.2)
26
26
  memory_profiler (~> 0.9)
27
27
  builder (3.2.4)
28
- committee (3.3.0)
28
+ committee (4.0.0)
29
29
  json_schema (~> 0.14, >= 0.14.3)
30
- openapi_parser (>= 0.6.1)
30
+ openapi_parser (>= 0.11.1)
31
31
  rack (>= 1.5)
32
32
  concurrent-ruby (1.1.6)
33
33
  deep_merge (1.2.1)
@@ -55,7 +55,7 @@ GEM
55
55
  dry-logic (~> 1.0, >= 1.0.2)
56
56
  ecma-re-validator (0.2.1)
57
57
  regexp_parser (~> 1.2)
58
- grape (1.3.2)
58
+ grape (1.3.3)
59
59
  activesupport
60
60
  builder
61
61
  dry-types (>= 1.1)
@@ -63,7 +63,7 @@ GEM
63
63
  rack (>= 1.3.0)
64
64
  rack-accept
65
65
  hana (1.3.6)
66
- hanami-router (2.0.0.alpha2)
66
+ hanami-router (2.0.0.alpha3)
67
67
  mustermann (~> 1.0)
68
68
  mustermann-contrib (~> 1.0)
69
69
  rack (~> 2.0)
@@ -72,7 +72,7 @@ GEM
72
72
  transproc (~> 1.0)
73
73
  hansi (0.2.0)
74
74
  hash-deep-merge (0.1.1)
75
- i18n (1.8.2)
75
+ i18n (1.8.3)
76
76
  concurrent-ruby (~> 1.0)
77
77
  json_schema (0.20.8)
78
78
  json_schemer (0.2.11)
@@ -82,7 +82,7 @@ GEM
82
82
  uri_template (~> 0.7)
83
83
  memory_profiler (0.9.14)
84
84
  mini_portile2 (2.4.0)
85
- minitest (5.14.0)
85
+ minitest (5.14.1)
86
86
  multi_json (1.14.1)
87
87
  mustermann (1.1.1)
88
88
  ruby2_keywords (~> 0.0.1)
@@ -101,14 +101,14 @@ GEM
101
101
  hash-deep-merge
102
102
  mustermann-contrib (~> 1.1.1)
103
103
  nokogiri
104
- openapi_parser (0.10.0)
105
- public_suffix (4.0.4)
104
+ openapi_parser (0.11.2)
105
+ public_suffix (4.0.5)
106
106
  rack (2.2.2)
107
107
  rack-accept (0.4.5)
108
108
  rack (>= 0.4)
109
109
  rack-protection (2.0.8.1)
110
110
  rack
111
- regexp_parser (1.7.0)
111
+ regexp_parser (1.7.1)
112
112
  ruby2_keywords (0.0.2)
113
113
  seg (1.2.0)
114
114
  sinatra (2.0.8.1)
@@ -5,7 +5,7 @@ require 'openapi_first'
5
5
 
6
6
  namespace = Module.new do
7
7
  def self.find_thing(params, _res)
8
- { hello: 'world', id: params.fetch('id') }
8
+ { hello: 'world', id: params.fetch(:id) }
9
9
  end
10
10
 
11
11
  def self.find_things(_params, _res)
@@ -13,4 +13,9 @@ module Web
13
13
  end
14
14
 
15
15
  oas_path = File.absolute_path('./openapi.yaml', __dir__)
16
- App = OpenapiFirst.app(oas_path, namespace: Web)
16
+
17
+ App = OpenapiFirst.app(
18
+ oas_path,
19
+ namespace: Web,
20
+ raise_error: OpenapiFirst.env == 'test'
21
+ )
@@ -8,7 +8,8 @@ require 'openapi_first/inbox'
8
8
  require 'openapi_first/router'
9
9
  require 'openapi_first/request_validation'
10
10
  require 'openapi_first/response_validator'
11
- require 'openapi_first/operation_resolver'
11
+ require 'openapi_first/response_validation'
12
+ require 'openapi_first/responder'
12
13
  require 'openapi_first/app'
13
14
 
14
15
  module OpenapiFirst
@@ -18,6 +19,10 @@ module OpenapiFirst
18
19
  INBOX = 'openapi_first.inbox'
19
20
  HANDLER = 'openapi_first.handler'
20
21
 
22
+ def self.env
23
+ ENV['RACK_ENV'] || ENV['HANAMI_ENV'] || ENV['RAILS_ENV']
24
+ end
25
+
21
26
  def self.load(spec_path, only: nil)
22
27
  content = YAML.load_file(spec_path)
23
28
  raw = OasParser::Parser.new(spec_path, content).resolve
@@ -26,14 +31,14 @@ module OpenapiFirst
26
31
  Definition.new(parsed)
27
32
  end
28
33
 
29
- def self.app(spec, namespace:)
34
+ def self.app(spec, namespace:, raise_error: false)
30
35
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
31
- App.new(nil, spec, namespace: namespace)
36
+ App.new(nil, spec, namespace: namespace, raise_error: raise_error)
32
37
  end
33
38
 
34
- def self.middleware(spec, namespace:)
39
+ def self.middleware(spec, namespace:, raise_error: false)
35
40
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
36
- AppWithOptions.new(spec, namespace: namespace)
41
+ AppWithOptions.new(spec, namespace: namespace, raise_error: raise_error)
37
42
  end
38
43
 
39
44
  class AppWithOptions
@@ -48,5 +53,42 @@ module OpenapiFirst
48
53
  end
49
54
 
50
55
  class Error < StandardError; end
51
- class ResponseCodeNotFoundError < Error; end
56
+ class NotFoundError < Error; end
57
+ class NotImplementedError < RuntimeError; end
58
+ class ResponseInvalid < Error; end
59
+ class ResponseCodeNotFoundError < ResponseInvalid; end
60
+ class ResponseContentTypeNotFoundError < ResponseInvalid; end
61
+ class ResponseBodyInvalidError < ResponseInvalid; end
62
+
63
+ class RequestInvalidError < Error
64
+ def initialize(serialized_errors)
65
+ message = error_message(serialized_errors)
66
+ super message
67
+ end
68
+
69
+ private
70
+
71
+ def error_message(errors)
72
+ errors.map do |error|
73
+ [human_source(error), human_error(error)].compact.join(' ')
74
+ end.join(', ')
75
+ end
76
+
77
+ def human_source(error)
78
+ return unless error[:source]
79
+
80
+ source_key = error[:source].keys.first
81
+ source = {
82
+ pointer: 'Request body invalid:',
83
+ parameter: 'Query parameter invalid:'
84
+ }.fetch(source_key, source_key)
85
+ name = error[:source].values.first
86
+ source += " #{name}" unless name.nil? || name.empty?
87
+ source
88
+ end
89
+
90
+ def human_error(error)
91
+ error[:title]
92
+ end
93
+ end
52
94
  end
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
+ require 'logger'
4
5
 
5
6
  module OpenapiFirst
6
7
  class App
7
- def initialize(
8
- parent_app,
9
- spec,
10
- namespace:
11
- )
8
+ def initialize(parent_app, spec, namespace:, raise_error:)
12
9
  @stack = Rack::Builder.app do
13
10
  freeze_app
14
- use OpenapiFirst::Router,
15
- spec: spec,
16
- namespace: namespace,
17
- parent_app: parent_app
18
- use OpenapiFirst::RequestValidation
19
- run OpenapiFirst::OperationResolver.new
11
+ use OpenapiFirst::Router, spec: spec, raise_error: raise_error, parent_app: parent_app
12
+ use OpenapiFirst::RequestValidation, raise_error: raise_error
13
+ use OpenapiFirst::ResponseValidation if raise_error
14
+ run OpenapiFirst::Responder.new(
15
+ spec: spec,
16
+ namespace: namespace
17
+ )
20
18
  end
21
19
  end
22
20
 
@@ -4,24 +4,15 @@ require_relative 'operation'
4
4
 
5
5
  module OpenapiFirst
6
6
  class Definition
7
+ attr_reader :filepath
8
+
7
9
  def initialize(parsed)
10
+ @filepath = parsed.path
8
11
  @spec = parsed
9
12
  end
10
13
 
11
14
  def operations
12
15
  @spec.endpoints.map { |e| Operation.new(e) }
13
16
  end
14
-
15
- def find_operation!(request)
16
- @spec
17
- .path_by_path(request.path)
18
- .endpoint_by_method(request.request_method.downcase)
19
- end
20
-
21
- def find_operation(request)
22
- find_operation!(request)
23
- rescue OasParser::PathNotFound, OasParser::MethodNotFound
24
- nil
25
- end
26
17
  end
27
18
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'utils'
4
+
5
+ module OpenapiFirst
6
+ class FindHandler
7
+ def initialize(spec, namespace)
8
+ @namespace = namespace
9
+ @handlers = spec.operations.each_with_object({}) do |operation, hash|
10
+ operation_id = operation.operation_id
11
+ handler = find_handler(operation_id)
12
+ if handler.nil?
13
+ warn "#{self.class.name} cannot not find handler for '#{operation.operation_id}' (#{operation.method} #{operation.path}). This operation will be ignored." # rubocop:disable Layout/LineLength
14
+ next
15
+ end
16
+ hash[operation_id] = handler
17
+ end
18
+ end
19
+
20
+ def [](operation_id)
21
+ @handlers[operation_id]
22
+ end
23
+
24
+ def find_handler(operation_id)
25
+ name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
26
+ return if name.nil?
27
+
28
+ catch :halt do
29
+ return find_class_method_handler(name) if name.include?('.')
30
+ return find_instance_method_handler(name) if name.include?('#')
31
+ end
32
+ method_name = Utils.underscore(name)
33
+ return unless @namespace.respond_to?(method_name)
34
+
35
+ @namespace.method(method_name)
36
+ end
37
+
38
+ def find_class_method_handler(name)
39
+ module_name, method_name = name.split('.')
40
+ klass = find_const(@namespace, module_name)
41
+ klass.method(Utils.underscore(method_name))
42
+ end
43
+
44
+ def find_instance_method_handler(name)
45
+ module_name, klass_name = name.split('#')
46
+ const = find_const(@namespace, module_name)
47
+ klass = find_const(const, klass_name)
48
+ return ->(params, res) { klass.new.call(params, res) } if klass.instance_method(:initialize).arity.zero?
49
+
50
+ ->(params, res) { klass.new(params.env).call(params, res) }
51
+ end
52
+
53
+ def find_const(parent, name)
54
+ name = Utils.classify(name)
55
+ throw :halt unless parent.const_defined?(name, false)
56
+
57
+ parent.const_get(name, false)
58
+ end
59
+ end
60
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require 'json_schemer'
4
5
  require_relative 'utils'
6
+ require_relative 'response_object'
5
7
 
6
8
  module OpenapiFirst
7
9
  class Operation
@@ -24,17 +26,38 @@ module OpenapiFirst
24
26
  @parameters_json_schema ||= build_parameters_json_schema
25
27
  end
26
28
 
29
+ def parameters_schema
30
+ @parameters_schema ||= parameters_json_schema && JSONSchemer.schema(parameters_json_schema)
31
+ end
32
+
27
33
  def content_type_for(status)
28
- content = @operation
29
- .response_by_code(status.to_s, use_default: true)
30
- .content
34
+ content = response_for(status)['content']
31
35
  content.keys[0] if content
36
+ end
37
+
38
+ def response_schema_for(status, content_type)
39
+ content = response_for(status)['content']
40
+ return if content.nil? || content.empty?
41
+
42
+ media_type = content[content_type]
43
+ unless media_type
44
+ message = "Response content type not found '#{content_type}' for '#{name}'"
45
+ raise ResponseContentTypeNotFoundError, message
46
+ end
47
+ media_type['schema']
48
+ end
49
+
50
+ def response_for(status)
51
+ @operation.response_by_code(status.to_s, use_default: true).raw
32
52
  rescue OasParser::ResponseCodeNotFound
33
- operation_name = "#{method.upcase} #{path}"
34
- message = "Response status code or default not found: #{status} for '#{operation_name}'" # rubocop:disable Layout/LineLength
53
+ message = "Response status code or default not found: #{status} for '#{name}'"
35
54
  raise OpenapiFirst::ResponseCodeNotFoundError, message
36
55
  end
37
56
 
57
+ def name
58
+ "#{method.upcase} #{path} (#{operation_id})"
59
+ end
60
+
38
61
  private
39
62
 
40
63
  def build_parameters_json_schema
@@ -4,12 +4,16 @@ require 'rack'
4
4
  require 'json_schemer'
5
5
  require 'multi_json'
6
6
  require_relative 'inbox'
7
+ require_relative 'router_required'
7
8
  require_relative 'validation_format'
8
9
 
9
10
  module OpenapiFirst
10
11
  class RequestValidation # rubocop:disable Metrics/ClassLength
11
- def initialize(app, _options = {})
12
+ prepend RouterRequired
13
+
14
+ def initialize(app, raise_error: false)
12
15
  @app = app
16
+ @raise = raise_error
13
17
  end
14
18
 
15
19
  def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -46,34 +50,32 @@ module OpenapiFirst
46
50
 
47
51
  parsed_request_body = parse_request_body!(body)
48
52
  errors = validate_json_schema(schema, parsed_request_body)
49
- if errors.any?
50
- halt(error_response(400, serialize_request_body_errors(errors)))
51
- end
53
+ halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
52
54
  env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
53
55
  end
54
56
 
55
57
  def parse_request_body!(body)
56
- MultiJson.load(body)
58
+ MultiJson.load(body, symbolize_keys: true)
57
59
  rescue MultiJson::ParseError => e
58
60
  err = { title: 'Failed to parse body as JSON' }
59
61
  err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
60
- halt(error_response(400, [err]))
62
+ halt_with_error(400, [err])
61
63
  end
62
64
 
63
65
  def validate_request_content_type!(content_type, operation)
64
66
  return if operation.request_body.content[content_type]
65
67
 
66
- halt(error_response(415))
68
+ halt_with_error(415)
67
69
  end
68
70
 
69
71
  def validate_request_body_presence!(body, operation)
70
72
  return unless operation.request_body.required && body.empty?
71
73
 
72
- halt(error_response(415, 'Request body is required'))
74
+ halt_with_error(415, 'Request body is required')
73
75
  end
74
76
 
75
77
  def validate_json_schema(schema, object)
76
- JSONSchemer.schema(schema).validate(object)
78
+ schema.validate(Utils.deep_stringify(object))
77
79
  end
78
80
 
79
81
  def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
@@ -83,8 +85,10 @@ module OpenapiFirst
83
85
  }
84
86
  end
85
87
 
86
- def error_response(status, errors = [default_error(status)])
87
- Rack::Response.new(
88
+ def halt_with_error(status, errors = [default_error(status)])
89
+ raise RequestInvalidError, errors if @raise
90
+
91
+ halt Rack::Response.new(
88
92
  MultiJson.dump(errors: errors),
89
93
  status,
90
94
  Rack::CONTENT_TYPE => 'application/vnd.api+json'
@@ -94,7 +98,8 @@ module OpenapiFirst
94
98
  def request_body_schema(content_type, operation)
95
99
  return unless operation
96
100
 
97
- operation.request_body.content[content_type]&.fetch('schema')
101
+ schema = operation.request_body.content[content_type]&.fetch('schema')
102
+ JSONSchemer.schema(schema) if schema
98
103
  end
99
104
 
100
105
  def serialize_request_body_errors(validation_errors)
@@ -112,10 +117,11 @@ module OpenapiFirst
112
117
  return unless json_schema
113
118
 
114
119
  params = filtered_params(json_schema, params)
115
- errors = JSONSchemer.schema(json_schema).validate(params)
116
- if errors.any?
117
- halt error_response(400, serialize_query_parameter_errors(errors))
118
- end
120
+ errors = validate_json_schema(
121
+ operation.parameters_schema,
122
+ params
123
+ )
124
+ halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
119
125
  env[PARAMETERS] = params
120
126
  env[INBOX].merge! params
121
127
  end
@@ -123,7 +129,8 @@ module OpenapiFirst
123
129
  def filtered_params(json_schema, params)
124
130
  json_schema['properties']
125
131
  .each_with_object({}) do |key_value, result|
126
- parameter_name, schema = key_value
132
+ parameter_name = key_value[0].to_sym
133
+ schema = key_value[1]
127
134
  next unless params.key?(parameter_name)
128
135
 
129
136
  value = params[parameter_name]