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.
@@ -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,57 @@
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
+ response = @app.call(env)
21
+ validate(response, operation)
22
+ response
23
+ end
24
+
25
+ def validate(response, operation)
26
+ status, headers, body = response.to_a
27
+ content_type = headers[Rack::CONTENT_TYPE]
28
+ raise ResponseInvalid, "Response has no content-type for '#{operation.name}'" unless content_type
29
+
30
+ response_schema = operation.response_schema_for(status, content_type)
31
+ validate_response_body(response_schema, body) if response_schema
32
+ end
33
+
34
+ private
35
+
36
+ def validate_response_body(schema, response)
37
+ full_body = +''
38
+ response.each { |chunk| full_body << chunk }
39
+ data = full_body.empty? ? {} : load_json(full_body)
40
+ errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
41
+ error_message_for(error)
42
+ end
43
+ raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
44
+ end
45
+
46
+ def load_json(string)
47
+ MultiJson.load(string)
48
+ rescue MultiJson::ParseError
49
+ string
50
+ end
51
+
52
+ def error_message_for(error)
53
+ err = ValidationFormat.error_details(error)
54
+ [err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
55
+ end
56
+ end
57
+ end
@@ -3,57 +3,20 @@
3
3
  require 'json_schemer'
4
4
  require 'multi_json'
5
5
  require_relative 'validation'
6
+ require_relative 'router'
6
7
 
7
8
  module OpenapiFirst
8
9
  class ResponseValidator
9
10
  def initialize(spec)
10
11
  @spec = spec
12
+ @router = Router.new(->(_env) {}, spec: spec, raise_error: true)
13
+ @response_validation = ResponseValidation.new(->(response) { response.to_a })
11
14
  end
12
15
 
13
16
  def validate(request, response)
14
- errors = validation_errors(request, response)
15
- Validation.new(errors || [])
16
- rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
17
- Validation.new([e.message])
18
- end
19
-
20
- private
21
-
22
- def validation_errors(request, response)
23
- content = response_for(request, response)&.content
24
- return unless content
25
-
26
- content_type = content[response.content_type]
27
- unless content_type
28
- return ["Content type not found: '#{response.content_type}'"]
29
- end
30
-
31
- response_schema = content_type['schema']
32
- return unless response_schema
33
-
34
- response_data = MultiJson.load(response.body)
35
- validate_json_schema(response_schema, response_data)
36
- end
37
-
38
- def validate_json_schema(schema, data)
39
- JSONSchemer.schema(schema).validate(data).to_a.map do |error|
40
- format_error(error)
41
- end
42
- end
43
-
44
- def format_error(error)
45
- ValidationFormat.error_details(error)
46
- .merge!(
47
- data_pointer: error['data_pointer'],
48
- schema_pointer: error['schema_pointer']
49
- ).tap do |formatted|
50
- end
51
- end
52
-
53
- def response_for(request, response)
54
- @spec
55
- .find_operation!(request)
56
- &.response_by_code(response.status.to_s, use_default: true)
17
+ env = request.env.dup
18
+ @router.call(env)
19
+ @response_validation.validate(response, env[OPERATION])
57
20
  end
58
21
  end
59
22
  end
@@ -6,64 +6,49 @@ require_relative 'utils'
6
6
 
7
7
  module OpenapiFirst
8
8
  class Router
9
- NOT_FOUND = Rack::Response.new('', 404).finish.freeze
10
-
11
- def initialize(app, options)
9
+ def initialize(
10
+ app,
11
+ spec:,
12
+ raise_error: false,
13
+ parent_app: nil
14
+ )
12
15
  @app = app
13
- @namespace = options.fetch(:namespace, nil)
14
- @parent_app = options.fetch(:parent_app, nil)
15
- @router = build_router(options.fetch(:spec).operations)
16
+ @parent_app = parent_app
17
+ @raise = raise_error
18
+ @filepath = spec.filepath
19
+ @router = build_router(spec.operations)
16
20
  end
17
21
 
18
22
  def call(env)
19
- endpoint = find_endpoint(env)
20
- return endpoint.call(env) if endpoint
21
- return @parent_app.call(env) if @parent_app
23
+ env[OPERATION] = nil
24
+ response = call_router(env)
25
+ status = response[0]
26
+ if UNKNOWN_ROUTE_STATUS.include?(status)
27
+ return @parent_app.call(env) if @parent_app # This should only happen if used via OpenapiFirst.middlware
22
28
 
23
- NOT_FOUND
29
+ raise_error(env) if @raise
30
+ end
31
+ response
24
32
  end
25
33
 
26
- def find_handler(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
27
- name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
28
- return if name.nil?
34
+ UNKNOWN_ROUTE_STATUS = [404, 405].freeze
35
+ ORIGINAL_PATH = 'openapi_first.path_info'
29
36
 
30
- if name.include?('.')
31
- module_name, method_name = name.split('.')
32
- klass = find_const(@namespace, module_name)
33
- return klass&.method(Utils.underscore(method_name))
34
- end
35
- if name.include?('#')
36
- module_name, klass_name = name.split('#')
37
- const = find_const(@namespace, module_name)
38
- klass = find_const(const, klass_name)
39
- if klass.instance_method(:initialize).arity.zero?
40
- return ->(params, res) { klass.new.call(params, res) }
41
- end
42
-
43
- return ->(params, res) { klass.new(params.env).call(params, res) }
44
- end
45
- method_name = Utils.underscore(name)
46
- return unless @namespace.respond_to?(method_name)
37
+ private
47
38
 
48
- @namespace.method(method_name)
39
+ def raise_error(env)
40
+ req = Rack::Request.new(env)
41
+ msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
42
+ raise NotFoundError, msg
49
43
  end
50
44
 
51
- private
52
-
53
- def find_endpoint(env)
54
- original_path_info = env[Rack::PATH_INFO]
55
- # Overwrite PATH_INFO temporarily, because hanami-router does not respect SCRIPT_NAME # rubocop:disable Layout/LineLength
45
+ def call_router(env)
46
+ # Changing and restoring PATH_INFO is needed, because Hanami::Router does not respect existing script_path
47
+ env[ORIGINAL_PATH] = env[Rack::PATH_INFO]
56
48
  env[Rack::PATH_INFO] = Rack::Request.new(env).path
57
- @router.recognize(env).endpoint
49
+ @router.call(env)
58
50
  ensure
59
- env[Rack::PATH_INFO] = original_path_info
60
- end
61
-
62
- def find_const(parent, name)
63
- name = Utils.classify(name)
64
- return unless parent.const_defined?(name, false)
65
-
66
- parent.const_get(name, false)
51
+ env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
67
52
  end
68
53
 
69
54
  def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -71,12 +56,7 @@ module OpenapiFirst
71
56
  operations.each do |operation|
72
57
  normalized_path = operation.path.gsub('{', ':').gsub('}', '')
73
58
  if operation.operation_id.nil?
74
- warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation." # rubocop:disable Layout/LineLength
75
- next
76
- end
77
- handler = @namespace && find_handler(operation.operation_id)
78
- if @namespace && handler.nil?
79
- 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
59
+ warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation."
80
60
  next
81
61
  end
82
62
  router.public_send(
@@ -84,8 +64,8 @@ module OpenapiFirst
84
64
  normalized_path,
85
65
  to: lambda do |env|
86
66
  env[OPERATION] = operation
87
- env[PARAMETERS] = Utils.deep_stringify(env['router.params'])
88
- env[HANDLER] = handler
67
+ env[PARAMETERS] = env['router.params']
68
+ env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH)
89
69
  @app.call(env)
90
70
  end
91
71
  )
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module RouterRequired
5
+ def call(env)
6
+ unless env.key?(OPERATION)
7
+ raise 'OpenapiFirst::Router missing in middleware stack. Did you forget adding OpenapiFirst::Router?'
8
+ end
9
+
10
+ super
11
+ end
12
+ end
13
+ end
@@ -5,6 +5,7 @@ module OpenapiFirst
5
5
  SIMPLE_TYPES = %w[string integer].freeze
6
6
 
7
7
  # rubocop:disable Metrics/MethodLength
8
+ # rubocop:disable Metrics/AbcSize
8
9
  def self.error_details(error)
9
10
  if error['type'] == 'pattern'
10
11
  {
@@ -23,9 +24,10 @@ module OpenapiFirst
23
24
  elsif error['schema'] == false
24
25
  { title: 'unknown fields are not allowed' }
25
26
  else
26
- { title: 'is not valid' }
27
+ { title: "is not valid: #{error['data'].inspect}" }
27
28
  end
28
29
  end
29
30
  # rubocop:enable Metrics/MethodLength
31
+ # rubocop:enable Metrics/AbcSize
30
32
  end
31
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '0.10.2'
4
+ VERSION = '0.12.0'
5
5
  end
@@ -32,13 +32,13 @@ Gem::Specification.new do |spec|
32
32
  spec.bindir = 'exe'
33
33
  spec.require_paths = ['lib']
34
34
 
35
- spec.add_dependency 'deep_merge', '>= 1.2.1'
36
- spec.add_dependency 'hanami-router', '~> 2.0.alpha2'
37
- spec.add_dependency 'hanami-utils', '~> 2.0.alpha1'
38
- spec.add_dependency 'json_schemer', '~> 0.2'
39
- spec.add_dependency 'multi_json', '~> 1.14'
40
- spec.add_dependency 'oas_parser', '~> 0.25.1'
41
- spec.add_dependency 'rack', '~> 2.2'
35
+ spec.add_runtime_dependency 'deep_merge', '>= 1.2.1'
36
+ spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha3'
37
+ spec.add_runtime_dependency 'hanami-utils', '~> 2.0.alpha1'
38
+ spec.add_runtime_dependency 'json_schemer', '~> 0.2'
39
+ spec.add_runtime_dependency 'multi_json', '~> 1.14'
40
+ spec.add_runtime_dependency 'oas_parser', '~> 0.25.1'
41
+ spec.add_runtime_dependency 'rack', '~> 2.2'
42
42
 
43
43
  spec.add_development_dependency 'bundler', '~> 2'
44
44
  spec.add_development_dependency 'rack-test', '~> 1'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.2
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-05-08 00:00:00.000000000 Z
11
+ date: 2020-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: deep_merge
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 2.0.alpha2
33
+ version: 2.0.alpha3
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 2.0.alpha2
40
+ version: 2.0.alpha3
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: hanami-utils
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -203,12 +203,16 @@ files:
203
203
  - lib/openapi_first/app.rb
204
204
  - lib/openapi_first/coverage.rb
205
205
  - lib/openapi_first/definition.rb
206
+ - lib/openapi_first/find_handler.rb
206
207
  - lib/openapi_first/inbox.rb
207
208
  - lib/openapi_first/operation.rb
208
- - lib/openapi_first/operation_resolver.rb
209
209
  - lib/openapi_first/request_validation.rb
210
+ - lib/openapi_first/responder.rb
211
+ - lib/openapi_first/response_object.rb
212
+ - lib/openapi_first/response_validation.rb
210
213
  - lib/openapi_first/response_validator.rb
211
214
  - lib/openapi_first/router.rb
215
+ - lib/openapi_first/router_required.rb
212
216
  - lib/openapi_first/utils.rb
213
217
  - lib/openapi_first/validation.rb
214
218
  - lib/openapi_first/validation_format.rb
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rack'
4
- require_relative 'inbox'
5
-
6
- module OpenapiFirst
7
- class OperationResolver
8
- def call(env)
9
- operation = env[OpenapiFirst::OPERATION]
10
- res = Rack::Response.new
11
- inbox = env[INBOX]
12
- handler = env[HANDLER]
13
- result = handler.call(inbox, res)
14
- res.write serialize(result) if result && res.body.empty?
15
- res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
16
- res.finish
17
- end
18
-
19
- private
20
-
21
- def serialize(result)
22
- return result if result.is_a?(String)
23
-
24
- MultiJson.dump(result)
25
- end
26
- end
27
- end