low_type 0.7.4 → 0.8.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5873e2b3ecceaa3a28a589739addeb22c0914c808b0e7edcfbad5b3194c8bc9
4
- data.tar.gz: 6a5f1d91ee6307859d743fe0a0958c498d253ea2665aae10d966682019ed7e2b
3
+ metadata.gz: a7a3792bcc3da1ed2798c92606fd0e4b75ffe054a6afd32b5c7e0afd42c34620
4
+ data.tar.gz: 95fd0724a694f585441caf354d74534acb3288204cfbee143157ee34d8c5f28a
5
5
  SHA512:
6
- metadata.gz: 391d16f40bbaad2191ab9bbe1d98b3a2607af954c57b03d7000af5a94b232a2b43695a344846676725b571e122adce0ca68ee123a809633a4107adbc14aa49ab
7
- data.tar.gz: 4bf9a082a30330a929b9190009da152755d45cb8e213203d206305f8a8fcd16fa19b9a893f40de05078cc73bc37809609b7bd9f2cdd1d5224ca1ebb079df9c13
6
+ metadata.gz: a53b6098fbab6c32cb108a44932cc96a2a3c3b6b1732e788675496bfc002c3287748e3e0b500a8f6be599d988029ed2d7d6a989b7c565d88af54c0b1c060a79a
7
+ data.tar.gz: 622216f5999480de5b5334c65ce3f97c47be1eae78b6e527293c30b328bced9f2783920f767dbfd3300011202fc567c84bccc37f6cf91cb1290765e4d528e4c7
@@ -0,0 +1,18 @@
1
+ require_relative 'sinatra_adapter'
2
+
3
+ module LowType
4
+ class AdapterLoader
5
+ class << self
6
+ def load(klass:, parser:, file_path:)
7
+ adaptor = nil
8
+
9
+ ancestors = klass.ancestors.map(&:to_s)
10
+ adaptor = SinatraAdapter.new(klass:, parser:, file_path:) if ancestors.include?('Sinatra::Base')
11
+
12
+ return if adaptor.nil?
13
+
14
+ adaptor
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,103 @@
1
+ require 'prism'
2
+
3
+ require_relative 'sinatra_return_proxy'
4
+ require_relative '../interfaces/adapter_interface'
5
+ require_relative '../proxies/file_proxy'
6
+ require_relative '../error_types'
7
+
8
+ module LowType
9
+ class Status < Integer; end
10
+ class Headers < Hash; end
11
+
12
+ # We don't use https://sinatrarb.com/extensions.html because we need to type check all Ruby methods (not just Sinatra) at a lower level.
13
+ class SinatraAdapter < AdapterInterface
14
+ def initialize(klass:, parser:, file_path:)
15
+ @klass = klass
16
+ @parser = parser
17
+ @file_path = file_path
18
+ end
19
+
20
+ def redefine_methods
21
+ method_calls = @parser.method_calls(method_names: [:get, :post, :patch, :put, :delete, :options, :query])
22
+
23
+ # Type check return values.
24
+ method_calls.each do |method_call|
25
+ arguments_node = method_call.compact_child_nodes.first
26
+ next unless arguments_node.is_a?(Prism::ArgumentsNode)
27
+
28
+ pattern = arguments_node.arguments.first.content
29
+
30
+ file = FileProxy.new(path: @file_path, line: method_call.start_line, scope: "#{@klass}##{method_call.name}")
31
+ params = [ParamProxy.new(type_expression: nil, name: :route, type: :req, position: 0, file:)]
32
+ return_proxy = return_proxy(method_node: method_call, pattern:, file:)
33
+ next unless return_proxy
34
+
35
+ route = "#{method_call.name.upcase} #{pattern}"
36
+ @klass.low_methods[route] = MethodProxy.new(name: method_call.name, params:, return_proxy:)
37
+
38
+ # We're in Sinatra now. Objects request/response are from Sinatra.
39
+ @klass.after pattern do
40
+ if (method_proxy = self.class.low_methods["#{request.request_method} #{pattern}"])
41
+ proxy = method_proxy.return_proxy
42
+
43
+ # Inclusive rather than exclusive validation. If one type/value is valid then there's no need to error.
44
+ valid_type = proxy.type_expression.types.any? do |type|
45
+ proxy.type_expression.validate(value: SinatraAdapter.reconstruct_return_value(type:, response:), proxy:)
46
+ end
47
+
48
+ # No valid types so let's return a server error to the client.
49
+ unless valid_type
50
+ proxy.type_expression.types.each do |type|
51
+ value = SinatraAdapter.reconstruct_return_value(type:, response:)
52
+ unless proxy.type_expression.validate(value:, proxy:)
53
+ status(500)
54
+ body(proxy.error_message(value: value.inspect))
55
+ break
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # The route's String/Array/Enumerable return value populates a Rack::Response object.
65
+ # This response also contains values added via Sinatra DSL's header()/body() methods.
66
+ # So reconstruct the return value from the response object, based on the return type.
67
+ def self.reconstruct_return_value(type:, response:)
68
+ valid_types = {
69
+ Integer => -> (response) { response.status },
70
+ String => -> (response) { response.body.first },
71
+ HTML => -> (response) { response.body.first },
72
+ JSON => -> (response) { response.body.first },
73
+
74
+ # TODO: Should these be Enumerable[T] instead? How would we match a Module of a class in a hash key?
75
+ # NOTE: These keys represent types, not type expressions.
76
+ # A type lives inside a type expression and is actually an instance representing that type.
77
+ [String] => -> (response) { response.body },
78
+ [Integer, String] => -> (response) { [response.status, *response.body] },
79
+ [Integer, Hash, String] => -> (response) { [response.status, response.headers, *response.body] },
80
+ }
81
+
82
+ raise AllowedTypeError, 'Did you mean "Response.finish"?' if type.to_s == 'Response'
83
+
84
+ if (reconstructed_value = valid_types[type])
85
+ return reconstructed_value.call(response)
86
+ else
87
+ raise AllowedTypeError, "Valid Sinatra return types: #{valid_types.keys.map(&:to_s).join(' | ')}"
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def return_proxy(method_node:, pattern:, file:)
94
+ return_type = Parser.return_type(method_node:)
95
+ return nil if return_type.nil?
96
+
97
+ expression = eval(return_type.slice).call
98
+ expression = TypeExpression.new(type: expression) unless TypeExpression === expression
99
+
100
+ SinatraReturnProxy.new(type_expression: expression, name: "#{method_node.name.upcase} #{pattern}", file:)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,18 @@
1
+ require_relative '../proxies/return_proxy'
2
+
3
+ module LowType
4
+ class SinatraReturnProxy < ReturnProxy
5
+ attr_reader :type_expression, :name
6
+
7
+ def initialize(type_expression:, name:, file:)
8
+ @type_expression = type_expression
9
+ @name = name
10
+ @file = file
11
+ end
12
+
13
+ def error_message(value:)
14
+ value = value[0...20] if value
15
+ "Invalid return value '#{value}' for method '#{@name}'. Valid types: '#{@type_expression.valid_types}'"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module LowType
2
+ class Boolean; end # TrueClass or FalseClass
3
+ class HTML < String; end
4
+ class JSON < String; end
5
+ end
data/lib/error_types.rb CHANGED
@@ -2,4 +2,5 @@ module LowType
2
2
  class ArgumentTypeError < TypeError; end;
3
3
  class LocalTypeError < TypeError; end;
4
4
  class ReturnTypeError < TypeError; end;
5
+ class AllowedTypeError < TypeError; end;
5
6
  end
@@ -0,0 +1,7 @@
1
+ module LowType
2
+ class AdapterInterface
3
+ def redefine_methods
4
+ raise NotImplementedError
5
+ end
6
+ end
7
+ end
@@ -3,24 +3,24 @@ require_relative 'proxies/local_proxy'
3
3
  require_relative 'type_expression'
4
4
  require_relative 'value_expression'
5
5
 
6
- module TypeAssignment
6
+ module LocalTypes
7
7
  class AssignmentError < StandardError; end
8
8
 
9
9
  def type(type_expression)
10
- object = type_expression.default_value
10
+ referenced_object = type_expression.default_value
11
11
 
12
- if !LowType.value?(object)
13
- raise AssignmentError, "Single-instance objects like #{object} are not supported"
12
+ if !LowType.value?(referenced_object)
13
+ raise AssignmentError, "Single-instance objects like #{referenced_object} are not supported"
14
14
  end
15
15
 
16
16
  last_caller = caller_locations(1, 1).first
17
17
  file = LowType::FileProxy.new(path: last_caller.path, line: last_caller.lineno, scope: 'local type')
18
18
  local_proxy = LowType::LocalProxy.new(type_expression:, name: self, file:)
19
- object.instance_variable_set('@local_proxy', local_proxy)
19
+ referenced_object.instance_variable_set('@local_proxy', local_proxy)
20
20
 
21
- type_expression.validate!(value: object, proxy: local_proxy)
21
+ type_expression.validate!(value: referenced_object, proxy: local_proxy)
22
22
 
23
- def object.with_type=(value)
23
+ def referenced_object.with_type=(value)
24
24
  local_proxy = self.instance_variable_get('@local_proxy')
25
25
  type_expression = local_proxy.type_expression
26
26
  type_expression.validate!(value:, proxy: local_proxy)
@@ -33,9 +33,9 @@ module TypeAssignment
33
33
  self
34
34
  end
35
35
 
36
- return object.value if object.is_a?(ValueExpression)
36
+ return referenced_object.value if referenced_object.is_a?(ValueExpression)
37
37
 
38
- object
38
+ referenced_object
39
39
  end
40
40
  alias_method :low_type, :type
41
41
 
@@ -43,19 +43,22 @@ module TypeAssignment
43
43
  LowType.value(type:)
44
44
  end
45
45
  alias_method :low_value, :value
46
- end
47
46
 
48
- module LowType
47
+ # Scoped to the class that includes LowTypes module.
49
48
  class Array < ::Array
50
- def self.[](type)
51
- return TypeExpression.new(type: [type]) if LowType.type?(type)
49
+ def self.[](*types)
50
+ if types.all? { |type| LowType.type?(type) }
51
+ return LowType::TypeExpression.new(type: [*types])
52
+ end
53
+
52
54
  super
53
55
  end
54
56
  end
55
57
 
58
+ # Scoped to the class that includes LowTypes module.
56
59
  class Hash < ::Hash
57
60
  def self.[](type)
58
- return TypeExpression.new(type:) if LowType.type?(type)
61
+ return LowType::TypeExpression.new(type:) if LowType.type?(type)
59
62
  super
60
63
  end
61
64
  end
data/lib/low_type.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'adapters/adapter_loader'
4
+ require_relative 'basic_types'
3
5
  require_relative 'redefiner'
4
6
  require_relative 'type_expression'
5
7
  require_relative 'value_expression'
@@ -10,22 +12,22 @@ module LowType
10
12
 
11
13
  # Array[] class method returns a type expression only for the duration of this "included" hook.
12
14
  array_class_method = Array.method('[]').unbind
13
- Array.define_singleton_method('[]') do |expression|
14
- TypeExpression.new(type: [(expression)])
15
+ Array.define_singleton_method('[]') do |*types|
16
+ TypeExpression.new(type: [*types])
15
17
  end
16
18
 
17
19
  # Hash[] class method returns a type expression only for the duration of this "included" hook.
18
20
  hash_class_method = Hash.method('[]').unbind
19
- Hash.define_singleton_method('[]') do |expression|
21
+ Hash.define_singleton_method('[]') do |type|
20
22
  # Support Pry which uses Hash[].
21
- unless LowType.type?(expression)
23
+ unless LowType.type?(type)
22
24
  Hash.define_singleton_method('[]', hash_class_method)
23
- result = Hash[expression]
25
+ result = Hash[type]
24
26
  Hash.method('[]').unbind
25
27
  return result
26
28
  end
27
29
 
28
- TypeExpression.new(type: expression)
30
+ TypeExpression.new(type:)
29
31
  end
30
32
 
31
33
  class << klass
@@ -40,6 +42,9 @@ module LowType
40
42
 
41
43
  klass.prepend LowType::Redefiner.redefine_methods(method_nodes: parser.instance_methods, klass:, private_start_line:, file_path:)
42
44
  klass.singleton_class.prepend LowType::Redefiner.redefine_methods(method_nodes: parser.class_methods, klass:, private_start_line:, file_path:)
45
+
46
+ adapter = AdapterLoader.load(klass:, parser:, file_path:)
47
+ adapter.redefine_methods if adapter
43
48
  ensure
44
49
  Array.define_singleton_method('[]', array_class_method)
45
50
  Hash.define_singleton_method('[]', hash_class_method)
@@ -49,27 +54,31 @@ module LowType
49
54
  # Public API.
50
55
 
51
56
  def config
52
- config = Struct.new(:type_assignment, :deep_type_check)
57
+ config = Struct.new(:local_types, :deep_type_check)
53
58
  @config ||= config.new(false, false)
54
59
  end
55
60
 
56
61
  def configure
57
62
  yield(config)
58
63
 
59
- if config.type_assignment
60
- require_relative 'type_assignment'
61
- include TypeAssignment
64
+ if config.local_types
65
+ require_relative 'local_types'
66
+ include LocalTypes
62
67
  end
63
68
  end
64
69
 
65
70
  # Internal API.
66
71
 
67
72
  def file_path(klass:)
68
- caller.find { |callee| callee.end_with?("<class:#{klass}>'") }.split(':').first
73
+ # Remove module namespaces from class.
74
+ class_name = klass.to_s.split(':').last
75
+ # The first class found regardless of namespace will be the class that did the include.
76
+ caller.find { |callee| callee.end_with?("<class:#{class_name}>'") }.split(':').first
69
77
  end
70
78
 
79
+ # TODO: Unit test this.
71
80
  def type?(type)
72
- type.respond_to?(:new) || type == Integer || (type.is_a?(::Hash) && type.keys.first.respond_to?(:new) && type.values.first.respond_to?(:new))
81
+ type.respond_to?(:new) || type == Integer || type == Symbol || (type.is_a?(::Hash) && type.keys.first.respond_to?(:new) && type.values.first.respond_to?(:new))
73
82
  end
74
83
 
75
84
  def value?(expression)
@@ -80,6 +89,4 @@ module LowType
80
89
  TypeExpression.new(default_value: ValueExpression.new(value: type))
81
90
  end
82
91
  end
83
-
84
- class Boolean; end # TrueClass or FalseClass
85
92
  end
data/lib/parser.rb CHANGED
@@ -5,23 +5,37 @@ module LowType
5
5
  attr_reader :parent_map, :instance_methods, :class_methods, :private_start_line
6
6
 
7
7
  def initialize(file_path:)
8
- root_node = Prism.parse_file(file_path).value
8
+ @root_node = Prism.parse_file(file_path).value
9
9
 
10
10
  parent_mapper = ParentMapper.new
11
- parent_mapper.visit(root_node)
11
+ parent_mapper.visit(@root_node)
12
12
  @parent_map = parent_mapper.parent_map
13
13
 
14
- method_visitor = MethodVisitor.new(@parent_map)
15
- root_node.accept(method_visitor)
14
+ method_visitor = MethodDefVisitor.new(@parent_map)
15
+ @root_node.accept(method_visitor)
16
16
 
17
17
  @instance_methods = method_visitor.instance_methods
18
18
  @class_methods = method_visitor.class_methods
19
19
  @private_start_line = method_visitor.private_start_line
20
20
  end
21
21
 
22
- def self.return_node(method_node:)
23
- # Only a lambda defined immediately after a method's parameters is considered a return type expression.
22
+ def method_calls(method_names:)
23
+ block_visitor = MethodCallVisitor.new(parent_map: @parent_map, method_names:)
24
+ @root_node.accept(block_visitor)
25
+ block_visitor.method_calls
26
+ end
27
+
28
+ # Only a lambda defined immediately after a method's parameters/block is considered a return type expression.
29
+ def self.return_type(method_node:)
30
+ # Method statements.
24
31
  statements_node = method_node.compact_child_nodes.find { |node| node.is_a?(Prism::StatementsNode) }
32
+
33
+ # Block statements.
34
+ if statements_node.nil?
35
+ block_node = method_node.compact_child_nodes.find { |node| node.is_a?(Prism::BlockNode) }
36
+ statements_node = block_node.compact_child_nodes.find { |node| node.is_a?(Prism::StatementsNode) } if block_node
37
+ end
38
+
25
39
  return nil if statements_node.nil? # Sometimes developers define methods without code inside them.
26
40
 
27
41
  node = statements_node.body.first
@@ -31,7 +45,7 @@ module LowType
31
45
  end
32
46
  end
33
47
 
34
- class MethodVisitor < Prism::Visitor
48
+ class MethodDefVisitor < Prism::Visitor
35
49
  attr_reader :class_methods, :instance_methods, :private_start_line
36
50
 
37
51
  def initialize(parent_map)
@@ -70,6 +84,23 @@ module LowType
70
84
  end
71
85
  end
72
86
 
87
+ class MethodCallVisitor < Prism::Visitor
88
+ attr_reader :method_calls
89
+
90
+ def initialize(parent_map:, method_names:)
91
+ @parent_map = parent_map
92
+ @method_names = method_names
93
+
94
+ @method_calls = []
95
+ end
96
+
97
+ def visit_call_node(node)
98
+ @method_calls << node if @method_names.include?(node.name)
99
+
100
+ super # Continue walking the tree.
101
+ end
102
+ end
103
+
73
104
  class ParentMapper < Prism::Visitor
74
105
  attr_reader :parent_map
75
106
 
data/lib/redefiner.rb CHANGED
@@ -79,7 +79,7 @@ module LowType
79
79
  RUBY
80
80
  )
81
81
 
82
- # Call method with only its required args to evaluate type expressions (which are stored as default values).
82
+ # Called with only required args present (as nil) and optional args omitted, to evaluate type expressions (which are stored as default values).
83
83
  typed_method.call(*required_args, **required_kwargs)
84
84
 
85
85
  # TODO: Write spec for this.
@@ -88,10 +88,10 @@ module LowType
88
88
  end
89
89
 
90
90
  def return_proxy(method_node:, file:)
91
- return_node = Parser.return_node(method_node:)
92
- return nil if return_node.nil?
91
+ return_type = Parser.return_type(method_node:)
92
+ return nil if return_type.nil?
93
93
 
94
- expression = eval(return_node.slice).call
94
+ expression = eval(return_type.slice).call
95
95
  expression = TypeExpression.new(type: expression) unless expression.is_a?(TypeExpression)
96
96
 
97
97
  ReturnProxy.new(type_expression: expression, name: method_node.name, file:)
@@ -29,6 +29,26 @@ module LowType
29
29
  @default_value == :LOW_TYPE_UNDEFINED
30
30
  end
31
31
 
32
+ # Called in situations where we want to be inclusive rather than exclusive and pass validation if one of the types is valid.
33
+ def validate(value:, proxy:)
34
+ if value.nil?
35
+ return true if @default_value.nil?
36
+ return false if required?
37
+ end
38
+
39
+ @types.each do |type|
40
+ return true if LowType.type?(type) && type <= value.class # Example: HTML is a subclass of String and should pass as a String.
41
+
42
+ # TODO: Shallow validation of enumerables could be made deeper with user config.
43
+ return true if type.class == ::Array && value.class == ::Array && type.first == value.first.class
44
+ if type.class == ::Hash && value.class == ::Hash && type.keys[0] == value.keys[0].class && type.values[0] == value.values[0].class
45
+ return true
46
+ end
47
+ end
48
+
49
+ false
50
+ end
51
+
32
52
  def validate!(value:, proxy:)
33
53
  if value.nil?
34
54
  return true if @default_value.nil?
@@ -37,8 +57,9 @@ module LowType
37
57
 
38
58
  @types.each do |type|
39
59
  return true if LowType.type?(type) && type == value.class
40
- # TODO: Shallow validation of enumerables could be made deeper with user config.
41
- return true if type.class == ::Array && value.class == ::Array && type.first == value.first.class
60
+ return true if type.class == ::Array && value.class == ::Array && array_types_match_values?(types: type, values: value)
61
+
62
+ # TODO: Shallow validation of hash could be made deeper with user config.
42
63
  if type.class == ::Hash && value.class == ::Hash && type.keys[0] == value.keys[0].class && type.values[0] == value.values[0].class
43
64
  return true
44
65
  end
@@ -50,6 +71,23 @@ module LowType
50
71
  raise proxy.error_type, e.message, backtrace_with_proxy(file_paths:, backtrace: e.backtrace, proxy:)
51
72
  end
52
73
 
74
+ def valid_types
75
+ types = @types.map { |type| type.inspect.to_s }
76
+ types = types + ['nil'] if @default_value.nil?
77
+ types.join(' | ')
78
+ end
79
+
80
+ private
81
+
82
+ def array_types_match_values?(types:, values:)
83
+ # TODO: Probably better to use an each that breaks early when types run out.
84
+ types.zip(values) do |type, value|
85
+ return false unless type === value
86
+ end
87
+
88
+ true
89
+ end
90
+
53
91
  def backtrace_with_proxy(file_paths:, proxy:, backtrace:)
54
92
  # Remove LowType file paths from the backtrace.
55
93
  filtered_backtrace = backtrace.reject { |line| file_paths.find { |file_path| line.include?(file_path) } }
@@ -61,13 +99,6 @@ module LowType
61
99
 
62
100
  [proxy_file_backtrace, *filtered_backtrace]
63
101
  end
64
-
65
- def valid_types
66
- types = @types.map { |type| type.inspect.to_s }
67
- types = types + ['nil'] if @default_value.nil?
68
-
69
- types.join(' | ')
70
- end
71
102
  end
72
103
  end
73
104
 
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LowType
4
- VERSION = '0.7.4'
4
+ VERSION = '0.8.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: low_type
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.4
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
@@ -17,8 +17,14 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - lib/adapters/adapter_loader.rb
21
+ - lib/adapters/sinatra_adapter.rb
22
+ - lib/adapters/sinatra_return_proxy.rb
23
+ - lib/basic_types.rb
20
24
  - lib/error_types.rb
25
+ - lib/interfaces/adapter_interface.rb
21
26
  - lib/interfaces/error_interface.rb
27
+ - lib/local_types.rb
22
28
  - lib/low_type.rb
23
29
  - lib/parser.rb
24
30
  - lib/proxies/file_proxy.rb
@@ -27,7 +33,6 @@ files:
27
33
  - lib/proxies/param_proxy.rb
28
34
  - lib/proxies/return_proxy.rb
29
35
  - lib/redefiner.rb
30
- - lib/type_assignment.rb
31
36
  - lib/type_expression.rb
32
37
  - lib/value_expression.rb
33
38
  - lib/version.rb