remote-resource 0.1.0

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +19 -0
  3. data/.gitignore +9 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +1156 -0
  6. data/.travis.yml +17 -0
  7. data/Gemfile +15 -0
  8. data/Guardfile +17 -0
  9. data/LICENSE.txt +21 -0
  10. data/Procfile.dev +5 -0
  11. data/README.md +314 -0
  12. data/Rakefile +12 -0
  13. data/bin/console +15 -0
  14. data/bin/setup +8 -0
  15. data/lib/remote_resource/association_builder.rb +47 -0
  16. data/lib/remote_resource/attribute_http_client.rb +41 -0
  17. data/lib/remote_resource/attribute_key.rb +26 -0
  18. data/lib/remote_resource/attribute_method_attacher.rb +117 -0
  19. data/lib/remote_resource/attribute_specification.rb +51 -0
  20. data/lib/remote_resource/attribute_storage_value.rb +63 -0
  21. data/lib/remote_resource/base/attributes.rb +44 -0
  22. data/lib/remote_resource/base/base_class_methods.rb +23 -0
  23. data/lib/remote_resource/base/dsl.rb +27 -0
  24. data/lib/remote_resource/base/rescue.rb +43 -0
  25. data/lib/remote_resource/base.rb +35 -0
  26. data/lib/remote_resource/bridge.rb +174 -0
  27. data/lib/remote_resource/configuration/logger.rb +24 -0
  28. data/lib/remote_resource/configuration/lookup_method.rb +24 -0
  29. data/lib/remote_resource/configuration/storage.rb +24 -0
  30. data/lib/remote_resource/errors.rb +40 -0
  31. data/lib/remote_resource/log_subscriber.rb +39 -0
  32. data/lib/remote_resource/lookup/default.rb +39 -0
  33. data/lib/remote_resource/notifications.rb +17 -0
  34. data/lib/remote_resource/railtie.rb +21 -0
  35. data/lib/remote_resource/scope_evaluator.rb +52 -0
  36. data/lib/remote_resource/storage/cache_control.rb +120 -0
  37. data/lib/remote_resource/storage/db_cache.rb +36 -0
  38. data/lib/remote_resource/storage/db_cache_factory.rb +38 -0
  39. data/lib/remote_resource/storage/memory.rb +27 -0
  40. data/lib/remote_resource/storage/null_storage_entry.rb +43 -0
  41. data/lib/remote_resource/storage/redis.rb +27 -0
  42. data/lib/remote_resource/storage/serializer.rb +15 -0
  43. data/lib/remote_resource/storage/serializers/marshal.rb +18 -0
  44. data/lib/remote_resource/storage/storage_entry.rb +69 -0
  45. data/lib/remote_resource/version.rb +3 -0
  46. data/lib/remote_resource.rb +34 -0
  47. data/remote-resource.gemspec +27 -0
  48. metadata +175 -0
data/.travis.yml ADDED
@@ -0,0 +1,17 @@
1
+ language: ruby
2
+
3
+ sudo: false
4
+
5
+ rvm:
6
+ - "2.3.0"
7
+
8
+ script: bundle exec rake spec
9
+
10
+ cache: bundler
11
+
12
+ env:
13
+ - COVERAGE=true
14
+
15
+ addons:
16
+ code_climate:
17
+ repo_token: 423daed64ecdf569121bd4b69d02dac79b01f9bb2b7270eb8498f77d744ecc19
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source 'https://rubygems.org'
2
+
3
+ group :test do
4
+ gem 'codeclimate-test-reporter'
5
+ gem 'simplecov-console'
6
+ end
7
+
8
+ group :development do
9
+ gem 'pg'
10
+ gem 'spirit_hands'
11
+ gem 'guard-rspec', require: false
12
+ gem 'terminal-notifier-guard'
13
+ end
14
+
15
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,17 @@
1
+ notification :terminal_notifier
2
+ ignore %r{.*/flycheck_.*}
3
+
4
+ rspec_options = {
5
+ cmd: 'bundle exec rspec',
6
+ title: 'RemoteResource Rspec',
7
+ run_all: {
8
+ cmd: 'COVERAGE=true bundle exec rspec -f progress',
9
+ message: 'To view coverage: open coverage/index.html'
10
+ }
11
+ }
12
+ guard :rspec, rspec_options do
13
+ watch(%r{^spec/.+_spec\.rb$})
14
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
15
+ watch('spec/spec_helper.rb') { 'spec' }
16
+ watch(%r{^spec/support/(.+)\.rb$}) { 'spec' }
17
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Chris Ewald
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/Procfile.dev ADDED
@@ -0,0 +1,5 @@
1
+ # Local services needed during development
2
+ #
3
+ # Use: `foreman start -f Procfile.dev`
4
+
5
+ redis: redis-server
data/README.md ADDED
@@ -0,0 +1,314 @@
1
+ # RemoteResource
2
+
3
+ [![Build Status](https://travis-ci.org/mkcode/remote-resource.svg?branch=master)](https://travis-ci.org/mkcode/remote-resource)
4
+ [![Code Climate](https://codeclimate.com/github/mkcode/remote-resource/badges/gpa.svg)](https://codeclimate.com/github/mkcode/remote-resource)
5
+ [![Test Coverage](https://codeclimate.com/github/mkcode/remote-resource/badges/coverage.svg)](https://codeclimate.com/github/mkcode/remote-resource/coverage)
6
+ [![Inline docs](http://inch-ci.org/github/mkcode/remote_resource.svg?branch=master)](http://inch-ci.org/github/mkcode/remote_resource)
7
+
8
+ Add resiliency, speed, and familiarity to the APIs your app relies on. Features:
9
+
10
+ * A simple DSL for resource oriented APIs.
11
+ * Work with foreign APIs in the same way you work with ActiveRecord
12
+ associations.
13
+ * Transparently caches API responses for major performance gains.
14
+ * Don't fail when APIs your app relies on are momentarily down.
15
+ * Respect your APIs Cache-Control header. Or don't. It's up to you.
16
+ * Configurable logging and error reporting.
17
+ * Trivial to add support for your new API client.
18
+
19
+ ## Getting started
20
+
21
+ __RemoteResource__ allows you to easily create `ActiveRecord` style domain
22
+ objects (or models) that represent a foreign API. These `remote_resources` can
23
+ be mixed in and associated with other ActiveRecord models in the same way you
24
+ work with all your other models. Using this pattern and these conventions yields
25
+ some major performance gains through caching and fast and simple development
26
+ through familiarity.
27
+
28
+ A few steps to get started:
29
+
30
+ Create a `remote_resource`, such as:
31
+
32
+ ```ruby
33
+ # in `app/remote_resources/github_user.rb`
34
+ class GithubUser < HasRemote::Resource
35
+ client { Octokit::Client.new }
36
+ resource { |client, scope| client.user(scope[:github_login]) }
37
+
38
+ attribute :id
39
+ attribute :avatar_url
40
+
41
+ ...
42
+ end
43
+ ```
44
+
45
+ Associate it with your ActiveRecord `User` model:
46
+
47
+ ```ruby
48
+ # in `app/models/user.rb`
49
+ class User < ActiveRecord::Base
50
+ has_remote :github_user, scope: :github_login
51
+
52
+ ...
53
+ end
54
+ ```
55
+
56
+ And you now have an associated remote resource, that you can use just like you
57
+ local models.
58
+
59
+ ```ruby
60
+ user = User.find(1)
61
+
62
+ user.github_user.login
63
+ user.github_user.avatar_url
64
+ ```
65
+
66
+ Behind the scene, `has_remote` evaluated the `scope` on user 1 and issued a get
67
+ request to the GitHub API for the GithubUser with (local) User #1's
68
+ github_login. The response is cached and future github_user calls will be fast!
69
+
70
+ ## Installation
71
+
72
+ Add this line to your application's Gemfile. __Please note the hyphen__
73
+
74
+ ```ruby
75
+ gem 'remote-resource'
76
+ ```
77
+
78
+ And then execute:
79
+
80
+ $ bundle
81
+
82
+ Or install it yourself as:
83
+
84
+ $ gem install remote_resource
85
+
86
+ ## Defining an RemoteResource
87
+
88
+ By convention, resource classes are located under app/remote_resources.
89
+ This folder is automatically added to your Rails eager loaded paths.
90
+
91
+ ```ruby
92
+ # In `app/remote_resources/github_user.rb
93
+ class GithubUserAttributes < RemoteResource::Base
94
+ client { Octokit::Client.new }
95
+
96
+ resource { |client, scope| client.user(scope[:github_login]) }
97
+
98
+ rescue_from Octokit::Unauthorized
99
+
100
+ attribute :id
101
+ attribute :avatar_url
102
+ attribute :url
103
+ end
104
+ ```
105
+
106
+ The are 4 class methods that are available to help define an (API) remote resource. They are:
107
+
108
+ * __client__: You return an instance of the web client that the API uses in a block. That block yields the scope. (More on scope later.)
109
+
110
+ * __resource__: Supply a block to the resource method that returns a a remote
111
+ resource. For example, the 'show user' response (GET /user/:github_login)
112
+ that returns information about a specific user. The return value should
113
+ respond to `to_hash` in order to be used with attribute. Optionally takes a
114
+ symbol argument, specifying a name so that it may be looked up later.
115
+
116
+ * __attribute__: A single piece of data from the resource (or web) response.
117
+ This will be mapped to a method later. Optionally takes a second symbol
118
+ argument referring to a non-default resource (with an argument).
119
+
120
+ * __rescue_from__: Works in the same way that ActionContoller's rescue_from
121
+ works. It takes one or many Error class(es), and either a block of a `:with`
122
+ option that refers to an instance method on this class. The block and
123
+ instance method both receive the error and an additional context argument.
124
+
125
+ Remote resource allows you to define any instance method you like on it, which
126
+ may be used by being instantiated itself or from an associated model.
127
+
128
+ The following instance methods are available within a RemoteResource::Base class.
129
+
130
+ * __client__ - returns the evaluated client block.
131
+
132
+ * __resource(resource_name)__ - returns the evaluated resource block for the
133
+ provided name. An optional argument returns the evaluated resource block with
134
+ that name.
135
+
136
+ * __with_error_handling__ - code executed within a block to this function will
137
+ have this classes' error handling (from the rescue_from methods) enabled. It
138
+ takes an optional options Hash which will be sent to error handling block or
139
+ method which to allow for context specific behavior.
140
+
141
+ * __the attributes__ - all of the attributes named in the class method are
142
+ available as methods in the instance. Attribute methods always return
143
+ strings.
144
+
145
+ ```ruby
146
+ # In `app/remote_resources/github_user.rb
147
+ class GithubUserAttributes < RemoteResource::Base
148
+ client { Octokit::Client.new }
149
+ resource { |client, scope| client.user(scope[:github_login]) }
150
+ attribute :name
151
+ rescue_from Octokit::Unauthorized, with: :swallow_validate
152
+
153
+ def markdown_summary
154
+ with_error_handling action: :get_markdown do
155
+ client.markdown "# A big hello to #{name}!!!"
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def handle_fetch(exception, context)
162
+ raise exception unless context[:action] == :validate
163
+ end
164
+ end
165
+ ```
166
+
167
+ In the above examples, the `markdown_summary` method returns a string containing
168
+ a small HTML fragment. The method body uses with evaluated client block which is
169
+ an Octokit client in this case. Before sending a string to be markdown-ified,
170
+ the `name` attribute is looked up. This is wrapped inside of a
171
+ `with_error_handling` block to catch any potential errors.
172
+
173
+ The private `handle_fetch` method above is a configured error handler, specified
174
+ on the above `rescue_from` call. In this case, it re-raises all Unauthorized
175
+ errors expect for when the action is :validate.
176
+
177
+ The above `markdown_summary` method may be used from an associated User as
178
+ follows. The `handle_fetch` method may not be used because it is private.
179
+
180
+ ```ruby
181
+ user = User.find(1)
182
+ user.github_user.markdown_summary
183
+ ```
184
+
185
+ ## Instantiating Remote Resources directly
186
+
187
+ The above `GithubUser` example may also be instantiated on it's own. The
188
+ initializer takes the scope argument as an options Hash. In this case, because
189
+ in our resource block, we use `scope[:github_login]`, we send a `:github_login`
190
+ option into the constructor. For example:
191
+
192
+ ```ruby
193
+ github_user = GithubUser.new(github_login: 'mkcode')
194
+ ```
195
+
196
+ Now that we have an instance, we may call any of our custom defined methods on it.
197
+
198
+ ```ruby
199
+ github_user.markdown_summary
200
+ #=> "<h1>A big hello to Chris Ewald!!!</h1>"
201
+ ```
202
+
203
+ We also may call any of our defined attributes. Ex:
204
+
205
+ ```ruby
206
+ github_user.name
207
+ #=> "Chris Ewald"
208
+ ```
209
+
210
+ ## The scope
211
+
212
+ The scope option evaluates the keys of the Hash on the object specifying it.
213
+ There are a few different ways to define the scope, but it is always sent into
214
+ the `client` and `resource` blocks as a symboled key / value Hash. Consider the
215
+ following lines evaluated inside a User model: `class User < ActiveRecord::Base`
216
+
217
+ * `has_remote :github_user, scope: { id: :github_id }` - The scope is a Hash.
218
+ The :github_id method will be called on the User and sent as the value of the
219
+ :id key into the RemoteResource. Ex: `scope = { id: 234562 }`
220
+
221
+ * `has_remote :github_user, scope: :github_id` - The scope is a single Symbol.
222
+ Like above, the :github_id method will be called on User, except the value
223
+ will be sent under a :github_id key. Ex: `scope = { github_id: 234562 }` This
224
+ is just a shorthand for when the method on the calling object and the scope
225
+ key are the same.
226
+
227
+ * `has_remote :github_user, scope: [:github_id, :access_token]` - The scope is
228
+ an Array. Both the :github_id and :access_token methods will be called on
229
+ User and sent in under the same keys.
230
+ Ex: `scope = { github_id: 234562, access_token: "af98f73qfh37ghf374h34rt9" }`
231
+
232
+ Once evaluated, scopes will remain frozen for the lifetime of a RemoteResource
233
+ instance. They are also used as piece of the cache_key.
234
+
235
+ ## Is or has
236
+
237
+ Two methods are available for your model classes. `has_remote` and
238
+ `embeds_remote`. They take all the same options and do mostly the same thing;
239
+ create a method on the calling object, which returns that records associated
240
+ RemoteResource instance. `embeds_remote` will go one step further and define all
241
+ of the attribute getter methods on the model class as well. This can be used to
242
+ create 'flat' domain objects which are backed by values from a remote API. This
243
+ is largely related to Inhertance vs Composition programming theory which you are
244
+ welcome to look up on your own time. RemoteResource supports both styles; 'Is'
245
+ through `embeds_remote` and 'has' through `has_remote`. If unsure, you should
246
+ prefer to use `has_remote` over `embeds_remote` to create a clear distinction
247
+ between your local and remote domain.
248
+
249
+ ## Extending other domain objects
250
+
251
+ If you do not use ActiveRecord in your app, you may still use remote_resource by
252
+ simply extending the Bridge module onto what class you use as your domain. The
253
+ `has_remote` and `embed_remote` methods will then be available. For example:
254
+
255
+ ```ruby
256
+ class MyPoro
257
+ extend RemoteResource::Bridge
258
+ has_remote :github_user
259
+ end
260
+ ```
261
+
262
+ ## Configuration
263
+
264
+ In a initializer, like `config/initializers/remote_resource.rb`, you may override the following options:
265
+
266
+ ```ruby
267
+ # Setup global storages. For now there is only redis and memory. Default is one
268
+ # Memory store.
269
+
270
+ require 'remote_resource/storage/redis'
271
+ RemoteResource.storages = [
272
+ RemoteResource::Storage::Redis.new( Redis.new(url:nil) )
273
+ ]
274
+
275
+ # Setup a logger
276
+
277
+ RemoteResource.logger = Logger.new(STDOUT)
278
+
279
+ # Setup a lookup method. Only default for now, but the `cache_control` option
280
+ # may be changed to true or false. True will always revalidate. False will never
281
+ # revalidate. :cache_control respects the Cache-Control header.
282
+
283
+ require 'remote_resource/lookup/default'
284
+ RemoteResource.lookup_method = RemoteResource::Lookup::Default.new(validate: true)
285
+ ```
286
+
287
+ ## Notifications
288
+
289
+ There are 4 ActiveSupport notifications that you may subscribe to, to do in depth profiling of this gem:
290
+
291
+ * find.remote_resource
292
+ * storage_lookup.remote_resource
293
+ * http_head.remote_resource
294
+ * http_get.remote_resource
295
+
296
+ ActiveSupport::Notifications.subscribe('http_get.remote_resource') do |name, _start, _fin, _id, _payload|
297
+ puts "HTTP_GET #{name}"
298
+ end
299
+
300
+ ## Development
301
+
302
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
303
+
304
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
305
+
306
+ ## Contributing
307
+
308
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/remote_resource. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
309
+
310
+
311
+ ## License
312
+
313
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
314
+
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
7
+
8
+ desc "Run RSpec with code coverage"
9
+ task :coverage do
10
+ ENV['COVERAGE'] = 'true'
11
+ Rake::Task["spec"].execute
12
+ end
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'api_cached_attributes'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+
11
+ require 'spirit_hands'
12
+ SpiritHands.app = 'ApiAttrs'
13
+
14
+ Pry.start
15
+
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,47 @@
1
+ module RemoteResource
2
+ # The AssociationBuilder class is responsible for defining a method(s) on a
3
+ # target object that refers to an associated Base class. The body of that
4
+ # method instantiates an attributes class.
5
+ class AssociationBuilder
6
+ attr_reader :base_class, :options
7
+
8
+ def initialize(base_class, options = {})
9
+ @base_class = base_class
10
+ @options = ensure_options(options)
11
+ end
12
+
13
+ def associated_with(target_class)
14
+ method_name = @options[:as]
15
+ set_associated_class(method_name, target_class)
16
+ define_association_method(method_name, target_class)
17
+ self
18
+ end
19
+
20
+ private
21
+
22
+ def remote_class_var(method)
23
+ "@#{method}_remote_class".to_sym
24
+ end
25
+
26
+ def set_associated_class(method, target_class)
27
+ target_class.instance_variable_set(remote_class_var(method), @base_class)
28
+ end
29
+
30
+ def define_association_method(method_name, target_class)
31
+ scope = @options[:scope]
32
+ target_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
33
+ def #{method_name}
34
+ scope_evaluator = RemoteResource::ScopeEvaluator.new(#{scope})
35
+ evaluated_scope = scope_evaluator.evaluate_on(self)
36
+ self.class.instance_variable_get(:#{remote_class_var(method_name)})
37
+ .new(evaluated_scope)
38
+ end
39
+ RUBY
40
+ end
41
+
42
+ def ensure_options(options)
43
+ options[:as] ||= @base_class.underscore
44
+ options
45
+ end
46
+ end
47
+ end