arkenstone-open 3.0.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.
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