knuverse-knufactor 0.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +14 -0
  5. data/.travis.yml +14 -0
  6. data/CONTRIBUTING.md +16 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE +22 -0
  9. data/README.md +125 -0
  10. data/Rakefile +10 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/knuverse-knufactor.gemspec +42 -0
  14. data/lib/core_extensions/string/transformations.rb +28 -0
  15. data/lib/knuverse/knufactor.rb +42 -0
  16. data/lib/knuverse/knufactor/api_client.rb +30 -0
  17. data/lib/knuverse/knufactor/api_client_base.rb +112 -0
  18. data/lib/knuverse/knufactor/api_exception.rb +7 -0
  19. data/lib/knuverse/knufactor/exceptions/api_client_not_configured.rb +9 -0
  20. data/lib/knuverse/knufactor/exceptions/immutable_modification.rb +9 -0
  21. data/lib/knuverse/knufactor/exceptions/invalid_arguments.rb +9 -0
  22. data/lib/knuverse/knufactor/exceptions/invalid_options.rb +9 -0
  23. data/lib/knuverse/knufactor/exceptions/invalid_property.rb +9 -0
  24. data/lib/knuverse/knufactor/exceptions/invalid_where_query.rb +9 -0
  25. data/lib/knuverse/knufactor/exceptions/missing_path.rb +9 -0
  26. data/lib/knuverse/knufactor/exceptions/new_instance_with_id.rb +9 -0
  27. data/lib/knuverse/knufactor/helpers/api_client.rb +36 -0
  28. data/lib/knuverse/knufactor/helpers/resource.rb +112 -0
  29. data/lib/knuverse/knufactor/helpers/resource_class.rb +49 -0
  30. data/lib/knuverse/knufactor/resource.rb +207 -0
  31. data/lib/knuverse/knufactor/resource_collection.rb +189 -0
  32. data/lib/knuverse/knufactor/resources/client.rb +44 -0
  33. data/lib/knuverse/knufactor/simple_api_client.rb +20 -0
  34. data/lib/knuverse/knufactor/validations/api_client.rb +42 -0
  35. data/lib/knuverse/knufactor/validations/resource.rb +17 -0
  36. data/lib/knuverse/knufactor/version.rb +10 -0
  37. metadata +220 -0
@@ -0,0 +1,7 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ # Generic Exception definition
4
+ class APIException < StandardError
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # The client must be configured before it can be used
5
+ class APIClientNotConfigured < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Provided when an attempt is made to modify an immutable resource
5
+ class ImmutableModification < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Missing, incorrect argument(s) were passed
5
+ class InvalidArguments < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Missing, incorrect, or extra options were passed
5
+ class InvalidOptions < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Provided when property definitions use inappropriate names
5
+ class InvalidProperty < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Provided when a query is constructed but is invalid
5
+ class InvalidWhereQuery < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # Provided when a method is called on an incomplete resource definition
5
+ class MissingPath < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Exceptions
4
+ # ID can not be manually specified on resources
5
+ class NewInstanceWithID < APIException
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Helpers
4
+ # Simple helper methods for the API Client
5
+ module APIClient
6
+ # Substitute characters with their JSON-supported versions
7
+ # @return [String]
8
+ def json_escape(s)
9
+ json_escape = {
10
+ '&' => '\u0026',
11
+ '>' => '\u003e',
12
+ '<' => '\u003c',
13
+ '%' => '\u0025',
14
+ "\u2028" => '\u2028',
15
+ "\u2029" => '\u2029'
16
+ }
17
+ json_escape_regex = /[\u2028\u2029&><%]/u
18
+
19
+ s.to_s.gsub(json_escape_regex, json_escape)
20
+ end
21
+
22
+ # Provides access to the "raw" underlying rest-client
23
+ # @return [RestClient::Resource]
24
+ def raw
25
+ connection
26
+ end
27
+
28
+ # The API Client version (uses Semantic Versioning)
29
+ # @return [String]
30
+ def version
31
+ VERSION
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,112 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Helpers
4
+ # Simple helper methods for Resources
5
+ module Resource
6
+ def datetime_from_params(params, actual_key)
7
+ DateTime.new(
8
+ params["#{actual_key}(1i)"].to_i,
9
+ params["#{actual_key}(2i)"].to_i,
10
+ params["#{actual_key}(3i)"].to_i,
11
+ params["#{actual_key}(4i)"].to_i,
12
+ params["#{actual_key}(5i)"].to_i
13
+ )
14
+ end
15
+
16
+ def fresh?
17
+ !tainted?
18
+ end
19
+
20
+ def id_property
21
+ self.class.properties.select { |_, opts| opts[:id_property] }.keys.first
22
+ end
23
+
24
+ def id
25
+ @entity[id_property.to_s]
26
+ end
27
+
28
+ def immutable?
29
+ self.class.immutable?
30
+ end
31
+
32
+ # ActiveRecord ActiveModel::Name compatibility method
33
+ def model_name
34
+ self.class
35
+ end
36
+
37
+ def new?
38
+ !@entity.key?(id_property.to_s)
39
+ end
40
+
41
+ def paths
42
+ self.class.paths
43
+ end
44
+
45
+ def path_for(kind)
46
+ self.class.path_for(kind)
47
+ end
48
+
49
+ # ActiveRecord ActiveModel::Model compatibility method
50
+ def persisted?
51
+ !new?
52
+ end
53
+
54
+ def properties
55
+ self.class.properties
56
+ end
57
+
58
+ def tainted?
59
+ @tainted ? true : false
60
+ end
61
+
62
+ # ActiveRecord ActiveModel::Conversion compatibility method
63
+ def to_key
64
+ new? ? [] : [id]
65
+ end
66
+
67
+ # ActiveRecord ActiveModel::Conversion compatibility method
68
+ def to_model
69
+ self
70
+ end
71
+
72
+ # ActiveRecord ActiveModel::Conversion compatibility method
73
+ def to_param
74
+ new? ? nil : id.to_s
75
+ end
76
+
77
+ # ActiveRecord ActiveModel compatibility method
78
+ def update(params)
79
+ new_params = {}
80
+ # need to convert multi-part datetime params
81
+ params.each do |key, value|
82
+ if key =~ /([^(]+)\(1i/
83
+ actual_key = key.match(/([^(]+)\(/)[1]
84
+ new_params[actual_key] = datetime_from_params(params, actual_key)
85
+ else
86
+ new_params[key] = value
87
+ end
88
+ end
89
+
90
+ new_params.each do |key, value|
91
+ setter_key = "#{key}=".to_sym
92
+ raise Exceptions::InvalidProperty unless respond_to?(setter_key)
93
+ send(setter_key, value)
94
+ end
95
+ save
96
+ end
97
+
98
+ def <=>(other)
99
+ if id < other.id
100
+ -1
101
+ elsif id > other.id
102
+ 1
103
+ elsif id == other.id
104
+ 0
105
+ else
106
+ raise Exceptions::InvalidArguments
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,49 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ module Helpers
4
+ # Simple helper class methods for Resource
5
+ module ResourceClass
6
+ # Produce a more human-readable representation of {#i18n_key}
7
+ # @note ActiveRecord ActiveModel::Name compatibility method
8
+ # @return [String]
9
+ def human
10
+ i18n_key.humanize
11
+ end
12
+
13
+ # Check if a resource class is immutable
14
+ def immutable?
15
+ @immutable ||= false
16
+ end
17
+
18
+ # A mock internationalization key based on the class name
19
+ # @note ActiveRecord ActiveModel::Name compatibility method
20
+ # @return [String]
21
+ def i18n_key
22
+ name.split('::').last.to_underscore
23
+ end
24
+
25
+ alias singular_route_key i18n_key
26
+
27
+ # A symbolized version of {#i18n_key}
28
+ # @note ActiveRecord ActiveModel::Name compatibility method
29
+ # @return [Symbol]
30
+ def param_key
31
+ i18n_key.to_sym
32
+ end
33
+
34
+ # All the properties defined for this Resource class
35
+ # @return [Hash{Symbol => Hash}]
36
+ def properties
37
+ @properties ||= {}
38
+ end
39
+
40
+ # A route key for building URLs
41
+ # @note ActiveRecord ActiveModel::Name compatibility method
42
+ # @return [String]
43
+ def route_key
44
+ i18n_key.en.plural
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,207 @@
1
+ module KnuVerse
2
+ module Knufactor
3
+ # A generic API resource
4
+ # TODO: Thread safety
5
+ class Resource
6
+ attr_accessor :client
7
+ attr_reader :errors
8
+
9
+ include Comparable
10
+ include Validations::Resource
11
+ include Helpers::Resource
12
+ extend Helpers::ResourceClass
13
+
14
+ # Can this type of resource be changed client-side?
15
+ def self.immutable(status)
16
+ unless status.is_a?(TrueClass) || status.is_a?(FalseClass)
17
+ raise Exceptions::InvalidArguments
18
+ end
19
+ @immutable = status
20
+ end
21
+
22
+ # Define a property for a model
23
+ # @!macro [attach] property
24
+ # The $1 property
25
+ # @todo add more validations on options and names
26
+ def self.property(name, options = {})
27
+ @properties ||= {}
28
+
29
+ invalid_prop_names = [
30
+ :>, :<, :'=', :class, :def,
31
+ :%, :'!', :/, :'.', :'?', :*, :'{}',
32
+ :'[]'
33
+ ]
34
+
35
+ raise(Exceptions::InvalidProperty) if invalid_prop_names.include?(name.to_sym)
36
+ @properties[name.to_sym] = options
37
+ end
38
+
39
+ # Set the URI path for a resource method
40
+ def self.path(kind, uri)
41
+ paths[kind.to_sym] = uri
42
+ end
43
+
44
+ # Create or set a class-level location to store URI paths for methods
45
+ # @return [Hash{Symbol => String}]
46
+ def self.paths
47
+ @paths ||= {}
48
+ end
49
+
50
+ def self.path_for(kind)
51
+ guess = kind.to_sym == :all ? route_key : "#{route_key}/#{kind}"
52
+ paths[kind.to_sym] || guess
53
+ end
54
+
55
+ def self.gen_getter_method(name, opts)
56
+ method_name = opts[:type] == :boolean ? "#{name}?" : name
57
+ define_method(method_name.to_sym) do
58
+ name_as_string = name.to_s
59
+ reload if @lazy && !@entity.key?(name_as_string)
60
+
61
+ case opts[:type]
62
+ when :time
63
+ if @entity[name_as_string] && !@entity[name_as_string].to_s.empty?
64
+ Time.parse(@entity[name_as_string].to_s).utc
65
+ end
66
+ else
67
+ @entity[name_as_string]
68
+ end
69
+ end
70
+ end
71
+
72
+ def self.gen_setter_method(name, opts)
73
+ define_method("#{name}=".to_sym) do |value|
74
+ raise Exceptions::ImmutableModification if immutable?
75
+ # TODO: allow specifying a list of allowed values and validating against it
76
+ @entity[name.to_s] = opts[:type] == :time ? Time.parse(value.to_s).utc : value
77
+ @tainted = true
78
+ @modified_properties << name.to_sym
79
+ end
80
+ end
81
+
82
+ def self.gen_property_methods
83
+ properties.each do |prop, opts|
84
+ # Getter methods
85
+ next if opts[:id_property]
86
+ gen_getter_method(prop, opts) unless opts[:write_only]
87
+
88
+ # Setter methods (don't make one for obviously read-only properties)
89
+ gen_setter_method(prop, opts) unless opts[:read_only]
90
+ end
91
+ end
92
+
93
+ def self.all(options = {})
94
+ # TODO: Add validations for options
95
+
96
+ # TODO: add validation checks for the required pieces
97
+ raise Exceptions::MissingPath unless path_for(:all)
98
+
99
+ api_client = options[:api_client] || APIClient.instance
100
+
101
+ root = name.split('::').last.en.plural.to_underscore
102
+ # TODO: do something with lazy requests...
103
+
104
+ ResourceCollection.new(
105
+ api_client.get(path_for(:all))[root].collect do |record|
106
+ new(
107
+ entity: record,
108
+ lazy: (options[:lazy] ? true : false),
109
+ tainted: false,
110
+ api_client: api_client
111
+ )
112
+ end,
113
+ type: self,
114
+ api_client: api_client
115
+ )
116
+ end
117
+
118
+ def self.get(id, options = {})
119
+ # TODO: Add validations for options
120
+ raise Exceptions::MissingPath unless path_for(:all)
121
+
122
+ api_client = options[:api_client] || APIClient.instance
123
+
124
+ new(
125
+ entity: api_client.get("#{path_for(:all)}/#{id}"),
126
+ lazy: false,
127
+ tainted: false,
128
+ api_client: api_client
129
+ )
130
+ end
131
+
132
+ def self.where(attribute, value, options = {})
133
+ # TODO: validate incoming options
134
+ options[:comparison] ||= value.is_a?(Regexp) ? :match : '=='
135
+ api_client = options[:api_client] || APIClient.instance
136
+ all(lazy: (options[:lazy] ? true : false), api_client: api_client).where(
137
+ attribute, value, comparison: options[:comparison]
138
+ )
139
+ end
140
+
141
+ def initialize(options = {})
142
+ # TODO: better options validations
143
+ raise Exceptions::InvalidOptions unless options.is_a?(Hash)
144
+
145
+ @entity = options[:entity] || {}
146
+
147
+ # Allows lazy-loading if we're told this is a lazy instance
148
+ # This means only the minimal attributes were fetched.
149
+ # This shouldn't be set by end-users.
150
+ @lazy = options.key?(:lazy) ? options[:lazy] : false
151
+ # This allows local, user-created instances to be differentiated from fetched
152
+ # instances from the backend API. This shouldn't be set by end-users.
153
+ @tainted = options.key?(:tainted) ? options[:tainted] : true
154
+ # This is the API Client used to get data for this resource
155
+ @api_client = options[:api_client] || APIClient.instance
156
+ @errors = {}
157
+ # A place to store which properties have been modified
158
+ @modified_properties = []
159
+
160
+ validate_mutability
161
+ validate_id
162
+
163
+ self.class.class_eval { gen_property_methods }
164
+ end
165
+
166
+ def destroy
167
+ raise Exceptions::ImmutableModification if immutable?
168
+ unless new?
169
+ @api_client.delete("#{path_for(:all)}/#{id}")
170
+ @lazy = false
171
+ @tainted = true
172
+ @entity.delete('id')
173
+ end
174
+ true
175
+ end
176
+
177
+ def reload
178
+ if new?
179
+ # Can't reload a new resource
180
+ false
181
+ else
182
+ @entity = @api_client.get("#{path_for(:all)}/#{id}")
183
+ @lazy = false
184
+ @tainted = false
185
+ true
186
+ end
187
+ end
188
+
189
+ def save
190
+ saveable_data = @entity.select do |prop, value|
191
+ pr = prop.to_sym
192
+ go = properties.key?(pr) && !properties[pr][:read_only] && !value.nil?
193
+ @modified_properties.uniq.include?(pr) if go
194
+ end
195
+
196
+ if new?
197
+ @entity = @api_client.post(path_for(:all).to_s, saveable_data)
198
+ @lazy = true
199
+ else
200
+ @api_client.put("#{path_for(:all)}/#{id}", saveable_data)
201
+ end
202
+ @tainted = false
203
+ true
204
+ end
205
+ end
206
+ end
207
+ end