openapi_first 0.10.1 → 0.12.0.alpha2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ pp OpenapiFirst.env == 'test'
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,41 @@ module OpenapiFirst
48
53
  end
49
54
 
50
55
  class Error < StandardError; end
56
+ class NotFoundError < Error; end
57
+ class NotImplementedError < RuntimeError; end
51
58
  class ResponseCodeNotFoundError < Error; end
59
+ class ResponseMediaTypeNotFoundError < Error; end
60
+ class ResponseBodyInvalidError < Error; end
61
+
62
+ class RequestInvalidError < Error
63
+ def initialize(serialized_errors)
64
+ message = error_message(serialized_errors)
65
+ super message
66
+ end
67
+
68
+ private
69
+
70
+ def error_message(errors)
71
+ errors.map do |error|
72
+ [human_source(error), human_error(error)].compact.join(' ')
73
+ end.join(', ')
74
+ end
75
+
76
+ def human_source(error)
77
+ return unless error[:source]
78
+
79
+ source_key = error[:source].keys.first
80
+ source = {
81
+ pointer: 'Request body invalid:',
82
+ parameter: 'Query parameter invalid:'
83
+ }.fetch(source_key, source_key)
84
+ name = error[:source].values.first
85
+ source += " #{name}" unless name.nil? || name.empty?
86
+ source
87
+ end
88
+
89
+ def human_error(error)
90
+ error[:title]
91
+ end
92
+ end
52
93
  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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'forwardable'
4
4
  require_relative 'utils'
5
+ require_relative 'response_object'
5
6
 
6
7
  module OpenapiFirst
7
8
  class Operation
@@ -25,16 +26,33 @@ module OpenapiFirst
25
26
  end
26
27
 
27
28
  def content_type_for(status)
28
- content = @operation
29
- .response_by_code(status.to_s, use_default: true)
30
- .content
29
+ content = response_for(status)['content']
31
30
  content.keys[0] if content
31
+ end
32
+
33
+ def response_schema_for(status, content_type)
34
+ content = response_for(status)['content']
35
+ return if content.nil? || content.empty?
36
+
37
+ media_type = content[content_type]
38
+ unless media_type
39
+ message = "Response content type not found: '#{content_type}' for '#{name}'"
40
+ raise ResponseMediaTypeNotFoundError, message
41
+ end
42
+ media_type['schema']
43
+ end
44
+
45
+ def response_for(status)
46
+ @operation.response_by_code(status.to_s, use_default: true).raw
32
47
  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
48
+ message = "Response status code or default not found: #{status} for '#{name}'"
35
49
  raise OpenapiFirst::ResponseCodeNotFoundError, message
36
50
  end
37
51
 
52
+ def name
53
+ "#{method.upcase} #{path} (#{operation_id})"
54
+ end
55
+
38
56
  private
39
57
 
40
58
  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
@@ -44,14 +48,20 @@ module OpenapiFirst
44
48
  schema = request_body_schema(content_type, operation)
45
49
  return unless schema
46
50
 
47
- parsed_request_body = MultiJson.load(body)
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(error_response(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
 
57
+ def parse_request_body!(body)
58
+ MultiJson.load(body)
59
+ rescue MultiJson::ParseError => e
60
+ err = { title: 'Failed to parse body as JSON' }
61
+ err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
62
+ halt(error_response(400, [err]))
63
+ end
64
+
55
65
  def validate_request_content_type!(content_type, operation)
56
66
  return if operation.request_body.content[content_type]
57
67
 
@@ -76,6 +86,8 @@ module OpenapiFirst
76
86
  end
77
87
 
78
88
  def error_response(status, errors = [default_error(status)])
89
+ raise RequestInvalidError, errors if @raise
90
+
79
91
  Rack::Response.new(
80
92
  MultiJson.dump(errors: errors),
81
93
  status,
@@ -105,9 +117,7 @@ module OpenapiFirst
105
117
 
106
118
  params = filtered_params(json_schema, params)
107
119
  errors = JSONSchemer.schema(json_schema).validate(params)
108
- if errors.any?
109
- halt error_response(400, serialize_query_parameter_errors(errors))
110
- end
120
+ halt error_response(400, serialize_query_parameter_errors(errors)) if errors.any?
111
121
  env[PARAMETERS] = params
112
122
  env[INBOX].merge! params
113
123
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require_relative 'inbox'
5
+ require_relative 'find_handler'
6
+
7
+ module OpenapiFirst
8
+ class Responder
9
+ def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
10
+ @resolver = resolver
11
+ @namespace = namespace
12
+ end
13
+
14
+ def call(env)
15
+ operation = env[OpenapiFirst::OPERATION]
16
+ res = Rack::Response.new
17
+ handler = find_handler(operation)
18
+ result = handler.call(env[INBOX], res)
19
+ res.write serialize(result) if result && res.body.empty?
20
+ res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
21
+ res.finish
22
+ end
23
+
24
+ private
25
+
26
+ def find_handler(operation)
27
+ handler = @resolver[operation.operation_id]
28
+ raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
29
+
30
+ handler
31
+ end
32
+
33
+ def serialize(result)
34
+ return result if result.is_a?(String)
35
+
36
+ MultiJson.dump(result)
37
+ end
38
+ end
39
+
40
+ class OperationResolver < Responder
41
+ def initialize(spec:, namespace:)
42
+ warn "#{self.class.name} was renamed to #{OpenapiFirst::Responder.name}"
43
+ super
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require_relative 'utils'
5
+
6
+ module OpenapiFirst
7
+ # Represents an OpenAPI Response Object
8
+ class ResponseObject
9
+ extend Forwardable
10
+ def_delegators :@parsed,
11
+ :content
12
+
13
+ def_delegators :@raw,
14
+ :[]
15
+
16
+ def initialize(parsed)
17
+ @parsed = parsed
18
+ @raw = parsed.raw
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+ require 'multi_json'
5
+ require_relative 'router_required'
6
+ require_relative 'validation'
7
+
8
+ module OpenapiFirst
9
+ class ResponseValidation
10
+ prepend RouterRequired
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ operation = env[OPERATION]
18
+ return @app.call(env) unless operation
19
+
20
+ status, headers, body = @app.call(env)
21
+ content_type = headers[Rack::CONTENT_TYPE]
22
+ response_schema = operation.response_schema_for(status, content_type)
23
+ validate_response_body(response_schema, body) if response_schema
24
+
25
+ [status, headers, body]
26
+ end
27
+
28
+ private
29
+
30
+ def halt(status, body = '')
31
+ throw :halt, [status, {}, body]
32
+ end
33
+
34
+ def error(message)
35
+ { title: message }
36
+ end
37
+
38
+ def error_response(status, errors)
39
+ Rack::Response.new(
40
+ MultiJson.dump(errors: errors),
41
+ status,
42
+ Rack::CONTENT_TYPE => 'application/vnd.api+json'
43
+ ).finish
44
+ end
45
+
46
+ def validate_response_body(schema, response)
47
+ full_body = +''
48
+ response.each { |chunk| full_body << chunk }
49
+ data = full_body.empty? ? {} : load_json(full_body)
50
+ errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
51
+ format_error(error)
52
+ end
53
+ raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
54
+ end
55
+
56
+ def load_json(string)
57
+ MultiJson.load(string)
58
+ rescue MultiJson::ParseError
59
+ string
60
+ end
61
+
62
+ def format_error(error)
63
+ err = ValidationFormat.error_details(error)
64
+ [err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
65
+ end
66
+ end
67
+ end