openapi_first 0.10.1 → 0.12.0.alpha2

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.
@@ -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