knuverse-knufactor 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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