arkenstone-open 3.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/codacy-analysis.yml +46 -0
  3. data/.gitignore +19 -0
  4. data/.rubocop.yml +5 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +16 -0
  7. data/Gemfile +21 -0
  8. data/Gemfile.lock +101 -0
  9. data/LICENSE.txt +11 -0
  10. data/README.md +87 -0
  11. data/Rakefile +36 -0
  12. data/arkenstone.gemspec +27 -0
  13. data/lib/arkenstone/associations/resources.rb +76 -0
  14. data/lib/arkenstone/associations.rb +389 -0
  15. data/lib/arkenstone/document.rb +289 -0
  16. data/lib/arkenstone/enumerable/query_list.rb +20 -0
  17. data/lib/arkenstone/errors/no_url_error.rb +14 -0
  18. data/lib/arkenstone/helpers.rb +18 -0
  19. data/lib/arkenstone/network/env.rb +19 -0
  20. data/lib/arkenstone/network/hook.rb +74 -0
  21. data/lib/arkenstone/network/network.rb +72 -0
  22. data/lib/arkenstone/query_builder.rb +84 -0
  23. data/lib/arkenstone/queryable.rb +38 -0
  24. data/lib/arkenstone/timestamps.rb +25 -0
  25. data/lib/arkenstone/validation/validation_error.rb +34 -0
  26. data/lib/arkenstone/validation/validations.rb +192 -0
  27. data/lib/arkenstone/version.rb +5 -0
  28. data/lib/arkenstone.rb +22 -0
  29. data/test/associations/test_document_overrides.rb +50 -0
  30. data/test/associations/test_has_and_belongs_to_many.rb +80 -0
  31. data/test/dummy/app/models/association.rb +35 -0
  32. data/test/dummy/app/models/superuser.rb +8 -0
  33. data/test/dummy/app/models/user.rb +10 -0
  34. data/test/spec_helper.rb +16 -0
  35. data/test/test_arkenstone.rb +354 -0
  36. data/test/test_arkenstone_hook_inheritance.rb +39 -0
  37. data/test/test_associations.rb +327 -0
  38. data/test/test_enumerables.rb +36 -0
  39. data/test/test_environment.rb +14 -0
  40. data/test/test_helpers.rb +18 -0
  41. data/test/test_hooks.rb +104 -0
  42. data/test/test_query_builder.rb +163 -0
  43. data/test/test_queryable.rb +59 -0
  44. data/test/test_validations.rb +197 -0
  45. metadata +133 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Raised if an Arkenstone document does not have a URL associated with it. A URL is set with the +url+ directive:
4
+ #
5
+ # class Widget
6
+ # url 'http://example.com/widgets'
7
+ # end
8
+ class NoUrlError < StandardError
9
+ class << self
10
+ def default_message
11
+ 'A `url` must be defined for the class.'
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Helpers
5
+ class << self
6
+ def included(base)
7
+ base.send :include, Arkenstone::Helpers::GeneralMethods
8
+ base.extend Arkenstone::Helpers::GeneralMethods
9
+ end
10
+ end
11
+
12
+ module GeneralMethods
13
+ def full_url(url)
14
+ url =~ %r{(/$)} ? url : "#{url}/"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ # Environment is a wrapper around most of the properties created in a network request.
5
+ # A raw net/http object doesn't allow for much customization after it is instantiated. This allows the caller to manipulate data via hooks before a request is created.
6
+ class Environment
7
+ attr_accessor :url, :verb, :body, :headers
8
+
9
+ def initialize(options)
10
+ options.each do |key, value|
11
+ send("#{key}=".to_sym, value) if respond_to? key
12
+ end
13
+ end
14
+
15
+ def to_s
16
+ "#{@verb} #{@url}\n#{@body}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ class Hook
5
+ def before_request(env); end
6
+
7
+ def after_complete(response); end
8
+
9
+ def encode_attributes(attributes); end
10
+
11
+ def on_error(response); end
12
+
13
+ class << self
14
+ ### Calls all of the available `before_request` hooks available for the class.
15
+ def call_request_hooks(klass, request)
16
+ call_hook klass, proc { |h| h.before_request request }
17
+ end
18
+
19
+ ### Calls all of the available `after_complete` hooks available for the class.
20
+ def call_response_hooks(klass, response)
21
+ call_hook klass, proc { |h| h.after_complete response }
22
+ end
23
+
24
+ ### Calls all of the available `on_error` hooks available for the class.
25
+ def call_error_hooks(klass, response)
26
+ call_hook klass, proc { |h| h.on_error response }
27
+ end
28
+
29
+ def all_hooks_for_class(klass)
30
+ all_hooks = []
31
+ if klass.arkenstone_inherit_hooks
32
+ klass.ancestors.each do |ancestor|
33
+ break if ancestor == Arkenstone::Associations::InstanceMethods
34
+ break unless ancestor.respond_to?(:arkenstone_hooks)
35
+
36
+ all_hooks.concat ancestor.arkenstone_hooks unless ancestor.arkenstone_hooks.nil?
37
+ end
38
+ else
39
+ all_hooks = klass.arkenstone_hooks
40
+ end
41
+ all_hooks
42
+ end
43
+
44
+ def has_hooks?(klass)
45
+ return true if klass_has_hooks? klass
46
+ if klass.respond_to?(:arkenstone_inherit_hooks) && klass.arkenstone_inherit_hooks
47
+ return klass.ancestors.any? { |ancestor| klass_has_hooks? ancestor }
48
+ end
49
+
50
+ false
51
+ end
52
+
53
+ def klass_has_hooks?(klass)
54
+ klass.respond_to?(:arkenstone_hooks) and klass.arkenstone_hooks and klass.arkenstone_hooks.count.positive?
55
+ end
56
+
57
+ def call_hook(klass, enumerator)
58
+ hooks = []
59
+ if klass.arkenstone_inherit_hooks == true
60
+ klass.ancestors.each do |ancestor|
61
+ break if ancestor == Arkenstone::Associations::InstanceMethods
62
+
63
+ if ancestor.respond_to?(:arkenstone_hooks) && !ancestor.arkenstone_hooks.nil?
64
+ hooks.concat ancestor.arkenstone_hooks
65
+ end
66
+ end
67
+ else
68
+ hooks = klass.arkenstone_hooks
69
+ end
70
+ hooks&.each(&enumerator)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Network
5
+ module ClassMethods
6
+ def send_request(url, verb, data = nil, call_hooks = true)
7
+ env = Arkenstone::Environment.new url:, verb:, body: data
8
+ Arkenstone::Hook.call_request_hooks self, env if call_hooks
9
+ response = Arkenstone::Network.send_request env
10
+ handle_response response
11
+ response
12
+ end
13
+
14
+ ### Takes appropriate action if the request was a success or failure.
15
+ def handle_response(response)
16
+ if Arkenstone::Network.response_is_success response
17
+ Arkenstone::Hook.call_response_hooks self, response
18
+ else
19
+ Arkenstone::Hook.call_error_hooks self, response
20
+ end
21
+ end
22
+ end
23
+
24
+ class << self
25
+ def included(base)
26
+ base.extend Arkenstone::Network::ClassMethods
27
+ end
28
+
29
+ ### All http requests go through here.
30
+ def send_request(request_env)
31
+ http = create_http request_env.url
32
+ request = build_request request_env.url, request_env.verb
33
+ set_request_data request, request_env.body
34
+ set_request_headers request, request_env.headers unless request_env.headers.nil?
35
+ http.request request
36
+ end
37
+
38
+ ### Determines if the response was successful.
39
+ # TODO: Refactor this to handle more status codes.
40
+ # TODO: How do we handle redirects (30x)?
41
+ def response_is_success(response)
42
+ %w[200 204].include? response.code
43
+ end
44
+
45
+ ### Creates the http object used for requests.
46
+ def create_http(url)
47
+ uri = URI(url)
48
+ http = Net::HTTP.new(uri.hostname, uri.port)
49
+ http.use_ssl = true if uri.scheme == 'https'
50
+ http
51
+ end
52
+
53
+ ### Builds a Net::HTTP request object for the appropriate verb.
54
+ def build_request(url, verb)
55
+ klass = Kernel.const_get('Net::HTTP').const_get(verb.capitalize)
56
+ klass.new URI(url)
57
+ end
58
+
59
+ ### Fills in the body of a request with the appropriate serialized data.
60
+ def set_request_data(request, data)
61
+ data = data.to_json unless data.instance_of?(String)
62
+ request.body = data
63
+ request.content_type = 'application/json'
64
+ end
65
+
66
+ ### Sets HTTP headers on the request.
67
+ def set_request_headers(request, headers)
68
+ headers.each { |key, val| request.add_field key, val }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ #
5
+ # = Builder
6
+ # Builds up the query from the DSL and returns a ruby hash.
7
+ class QueryBuilder
8
+ ### Initializes the @hash variable, which is used to build complex queries.
9
+ def initialize
10
+ @cache = {}
11
+ end
12
+
13
+ ### Main entry point for processing the DSL.
14
+ def build(&)
15
+ result = instance_eval(&)
16
+ result = flush.merge result unless @cache.empty?
17
+ result.to_json
18
+ end
19
+
20
+ ### Finds the entries that have a value for a column in the provided +value_array+.
21
+ def _in(value_array)
22
+ { '$in' => value_array }
23
+ end
24
+
25
+ ### Finds entries who's values for a column are greater than the value provided.
26
+ def _gt(val)
27
+ { '$gt' => val }
28
+ end
29
+
30
+ ### Finds entries who's values for a column are greater than or equal to the value provided.
31
+ def _gte(val)
32
+ { '$gte' => val }
33
+ end
34
+
35
+ ### Finds entries who's values for a column are less than the value provided.
36
+ def _lt(val)
37
+ { '$lt' => val }
38
+ end
39
+
40
+ ### Finds entries who's values for a column are less than or equal to the value provided.
41
+ def _lte(val)
42
+ { '$lte' => val }
43
+ end
44
+
45
+ ### Finds entries that match *all* expressions in the value provided.
46
+ def _and(*vals)
47
+ evaluate_expression '$and', vals
48
+ end
49
+
50
+ ### Finds entries that match *any* expression in the value provided.
51
+ def _or(*vals)
52
+ evaluate_expression '$or', vals
53
+ end
54
+
55
+ ### Finds entries that do *not* match any expression in the value provided.
56
+ def _not(*vals)
57
+ evaluate_expression '$not', vals
58
+ end
59
+
60
+ ### Adds include statements for the database endpoint to parse.
61
+ def _include(values)
62
+ @cache = evaluate_expression '$include', values
63
+ end
64
+
65
+ ### Sets a max number of results to return.
66
+ def _limit(max)
67
+ @cache = evaluate_expression '$limit', max
68
+ end
69
+
70
+ private
71
+
72
+ ### Evaluates an expression that takes multiple arguments. Used to walk through nested boolean statements.
73
+ def evaluate_expression(bool_type, hash)
74
+ { bool_type.to_s => hash }
75
+ end
76
+
77
+ ### Spits out the stored expressions in the +hash+ and resets it.
78
+ def flush
79
+ result = @cache
80
+ @cache = {}
81
+ result
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Queryable
5
+ class << self
6
+ def included(base)
7
+ base.extend Arkenstone::Queryable::ClassMethods
8
+ end
9
+ end
10
+
11
+ module ClassMethods
12
+ def query_url
13
+ "#{full_url(arkenstone_url)}query"
14
+ end
15
+
16
+ def where(query = nil, &)
17
+ check_for_url
18
+ body = build_where_body(query, &)
19
+ return nil if body.nil?
20
+
21
+ # TODO: - refactor the network stuff into it's own module, so that we don't have `self` here
22
+ response = send_request query_url, :post, body
23
+ parse_all response.body if Arkenstone::Network.response_is_success response
24
+ end
25
+
26
+ def build_where_body(query = nil, &)
27
+ if query.instance_of?(String)
28
+ body = query
29
+ elsif query.instance_of?(Hash)
30
+ body = query.to_json
31
+ elsif query.nil? && block_given?
32
+ builder = Arkenstone::QueryBuilder.new
33
+ body = builder.build(&)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Timestamps
5
+ class << self
6
+ def included(base)
7
+ base.send :include, Arkenstone::Timestamps::InstanceMethods
8
+ end
9
+ end
10
+
11
+ module InstanceMethods
12
+ attr_accessor :created_at, :updated_at
13
+
14
+ def timestampable
15
+ true
16
+ end
17
+
18
+ def timestamp
19
+ current_time = Time.now
20
+ self.created_at = current_time unless created_at
21
+ self.updated_at = current_time
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Validation
5
+ class ValidationError
6
+ attr_accessor :messages
7
+
8
+ def initialize
9
+ @messages = {}
10
+ end
11
+
12
+ def count
13
+ @messages.count
14
+ end
15
+
16
+ def [](key)
17
+ @messages[key]
18
+ end
19
+
20
+ def []=(key, val)
21
+ @messages[key] = val
22
+ end
23
+
24
+ def add(attr, message)
25
+ errors_for_attr = @messages[attr]
26
+ if errors_for_attr.nil?
27
+ @messages[attr] = [message]
28
+ else
29
+ errors_for_attr << message
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ module Validation
5
+ class << self
6
+ def included(base)
7
+ base.send :include, Arkenstone::Validation::InstanceMethods
8
+ base.extend Arkenstone::Validation::ClassMethods
9
+ end
10
+ end
11
+
12
+ module InstanceMethods
13
+ attr_accessor :errors
14
+
15
+ ### Does a model's attributes pass all of the validation requirements?
16
+ def valid?
17
+ validate
18
+ @errors.count.zero?
19
+ end
20
+
21
+ # Run through all the validators.
22
+ def validate
23
+ @errors = Arkenstone::Validation::ValidationError.new
24
+ validate_with_validators
25
+ validate_with_custom_validators
26
+ end
27
+
28
+ private
29
+
30
+ # Loops through the provided validators. A validator is passed when a validation method returns nil. All other values are treated as errors.
31
+ def validate_with_validators
32
+ return if self.class.fields_to_validate.nil?
33
+
34
+ self.class.fields_to_validate.each do |attr, validators|
35
+ validators.each do |validator_hash, _arg|
36
+ key = validator_hash.keys.first
37
+ validation_method = "validate_#{key}"
38
+ options = validator_hash[key]
39
+ result = send validation_method, attr, options
40
+ @errors.add(attr, result) unless result.nil?
41
+ end
42
+ end
43
+ end
44
+
45
+ # Loops through all the custom validators created with `validate`.
46
+ def validate_with_custom_validators
47
+ self.class.custom_validators&.each do |custom_validator|
48
+ send custom_validator
49
+ end
50
+ end
51
+
52
+ # Checks if the attribute is on the instance of the model. If the attribute is a string, checks if it's empty.
53
+ # Example:
54
+ #
55
+ # validates :name, presence: true
56
+ #
57
+ def validate_presence(attr, options)
58
+ message = options[:message] || "can't be blank"
59
+ test = options[:presence]
60
+ method_not_defined = test != self.class.method_defined?(attr)
61
+ if method_not_defined
62
+ message
63
+ else
64
+ val = send(attr)
65
+ value_is_nil = test == val.nil?
66
+ return message if value_is_nil
67
+
68
+ value_is_string = val.instance_of?(String)
69
+ message if value_is_string && val.empty?
70
+ end
71
+ end
72
+
73
+ def validate_empty(attr, options)
74
+ message = options[:message] || 'must not be empty'
75
+ val = send(attr)
76
+ return message if val.nil?
77
+ return message if val.respond_to? :empty
78
+ return message if val.empty?
79
+ end
80
+
81
+ # Checks if an attribute is the appropriate boolean value.
82
+ #
83
+ # Example:
84
+ #
85
+ # validates :accepts_terms, acceptance: true
86
+ def validate_acceptance(attr, options)
87
+ acceptance = options[:acceptance]
88
+ message = options[:message] || "must be #{acceptance}"
89
+ val = send(attr)
90
+ message if val != acceptance
91
+ end
92
+
93
+ # Checks if an attribute is an instance of a specific type, or one of its descendents.
94
+ #
95
+ # Example:
96
+ #
97
+ # validates :should_be_string, type: String
98
+ #
99
+ # That will check if `should_be_string` is a `String` or a subclass of `String`
100
+ def validate_type(attr, options)
101
+ type = options[:type]
102
+ message = options[:message] || "must be type #{type}"
103
+ val = send(attr)
104
+ message if !val.nil? && !(val.is_a? type)
105
+ end
106
+
107
+ # Checks if the attribute conforms with the provided regular expression.
108
+ # Example:
109
+ #
110
+ # validates :name, with: format: { with: /\d+/, message: "must be lowercase" }
111
+ def validate_format(attr, options)
112
+ val = send attr
113
+ regex = options[:format][:with]
114
+ options[:format][:message] if regex.match(val).nil?
115
+ end
116
+
117
+ # Checks if the attribute is on the instance of the model. If the attribute is a string, checks if it's empty.
118
+ # Example:
119
+ # attr_accessor :email_confirmation
120
+ # attributes :email
121
+ #
122
+ # validates :email, confirmation: true
123
+ #
124
+ def validate_confirmation(attr, options)
125
+ message = options[:message] || "confirmation does not match #{attr}"
126
+ test = options[:confirmation]
127
+ attr_confirmation = "#{attr}_confirmation".to_sym
128
+
129
+ confirmation_not_found = test = !self.class.method_defined?(attr_confirmation) # attr_confirmation not defined
130
+ if confirmation_not_found
131
+ message
132
+ else
133
+ send(attr)
134
+ return message if send(attr) != send(attr_confirmation)
135
+ end
136
+ end
137
+ end
138
+
139
+ module ClassMethods
140
+ class << self
141
+ def extended(base)
142
+ base.fields_to_validate = {}
143
+ end
144
+ end
145
+
146
+ attr_accessor :fields_to_validate, :custom_validators
147
+
148
+ # Adds a custom validator method. Custom validators are responsible for adding errors to the `errors` hash.
149
+ # Example:
150
+ #
151
+ # class MyClass
152
+ # validate :special_case
153
+ #
154
+ # def special_case
155
+ # if 1 == 2
156
+ # errors.add(:the_bad_property, "Error Message")
157
+ # end
158
+ # end
159
+ # end
160
+ def validate(custom_validation_method)
161
+ self.custom_validators = [] if custom_validators.nil?
162
+ custom_validators << custom_validation_method
163
+ end
164
+
165
+ # Adds one of the provided validators to an attribute. Specify the validator in the `options` splat.
166
+ # Example:
167
+ #
168
+ # class MyClass
169
+ # validates :name, presence: true
170
+ # validates :email, with: format: { with: /[a-z]+/, message: "must be valid email" }
171
+ # end
172
+ def validates(attr, *options)
173
+ self.fields_to_validate = {} if fields_to_validate.nil?
174
+ sym = attr.downcase.to_sym
175
+ fields_for_attr = fields_to_validate[sym]
176
+ if fields_for_attr.nil?
177
+ fields_for_attr = []
178
+ fields_to_validate[sym] = fields_for_attr
179
+ end
180
+ fields_for_attr << create_validator(Hash[*options])
181
+ end
182
+
183
+ ### Creates a validator hash from the options passed into a `validates` method.
184
+ def create_validator(options_hash)
185
+ key = options_hash.first[0]
186
+ validator = {}
187
+ validator[key] = options_hash
188
+ validator
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arkenstone
4
+ VERSION = '3.0.1'
5
+ end
data/lib/arkenstone.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'arkenstone/version'
5
+ require 'arkenstone/errors/no_url_error'
6
+ require 'arkenstone/enumerable/query_list'
7
+ require 'arkenstone/network/hook'
8
+ require 'arkenstone/network/env'
9
+ require 'arkenstone/network/network'
10
+ require 'arkenstone/associations'
11
+ require 'arkenstone/associations/resources'
12
+ require 'arkenstone/validation/validation_error'
13
+ require 'arkenstone/validation/validations'
14
+ require 'arkenstone/query_builder'
15
+ require 'arkenstone/queryable'
16
+ require 'arkenstone/document'
17
+ require 'arkenstone/timestamps'
18
+ require 'arkenstone/helpers'
19
+
20
+ module Arkenstone
21
+ # Your code goes here...
22
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ class DocumentOverridesTest < Test::Unit::TestCase
6
+ def test_reload_override
7
+ eval %(
8
+ class Brand
9
+ include Arkenstone::Document
10
+
11
+ url 'http://example.com/brands'
12
+ attributes :name
13
+ has_many :balls
14
+ end
15
+
16
+ class Ball
17
+ include Arkenstone::Document
18
+
19
+ attributes :color
20
+ belongs_to :brand
21
+ end
22
+ )
23
+
24
+ stub_request(:post, "#{Brand.arkenstone_url}/").to_return(status: '200', body: { id: 1, name: 'Foo' }.to_json)
25
+ stub_request(:get, "#{Brand.arkenstone_url}/1/balls").to_return(status: 200, body: [].to_json)
26
+
27
+ brand = Brand.create(name: 'Foo')
28
+ assert(!brand.arkenstone_data[:balls])
29
+
30
+ stub_request(:post, "#{Brand.arkenstone_url}/1/balls/").to_return(status: '200',
31
+ body: {
32
+ id: 1, color: 'blue', brand_id: brand.id
33
+ }.to_json)
34
+
35
+ ball = brand.balls.create(color: 'blue')
36
+ assert(brand.arkenstone_data[:balls])
37
+
38
+ stub_request(:get, "#{Ball.arkenstone_url}/1").to_return(status: 200,
39
+ body: {
40
+ id: 1, color: 'blue', brand_id: brand.id
41
+ }.to_json)
42
+ ball.reload
43
+
44
+ assert(ball.color == 'blue')
45
+
46
+ found_ball = Ball.find(ball.id)
47
+ assert(found_ball.color == 'blue')
48
+ assert_equal(ball.to_json, found_ball.to_json)
49
+ end
50
+ end