remote_record 0.2.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50ea0ab82ff4f54ea0a877f85d2077f8e735da22b607d191dee6dad31a592a5c
4
- data.tar.gz: d9a28e40ed1b61915e77d80e544aab33f9acf934c941a6fee3a38d08429928a4
3
+ metadata.gz: d657d0ec1a8c4606cc656d2c557e592637fa50d165cd95e118a8efa3094bc7a7
4
+ data.tar.gz: 6bd9609c2617e49b9701e84cd1011236978233856864791e7126c221228addf3
5
5
  SHA512:
6
- metadata.gz: b1d370cfea855f1144e77ef2baacb4fcff519a00455e865b6beb6d8a280534e59f075b947c2775859a0f3d50d70d8c812463c4ce3d31857a00918a3c0f0c7072
7
- data.tar.gz: 35c957d7a2e7f2a1075e375ca75d9ed2266b399d0c24d7ea83a9248f1ba40fe3f68e2cabd3901d92e302f92987e289c96ebe851672d143eeeb3f369e54908b76
6
+ metadata.gz: 4a115d1e2e29d138e67a70c574d66cb7efca0c1ab239d9f47146d8e157410c68d700c8c4bf2c19db647d45a3831f669984117db4823b16691d53af07245ccec1
7
+ data.tar.gz: 7325785485edb863a7d5c91e10b80b8119c93c1ec8b5ff7b1f570ffd498ac297b4c1ba8e3e86549d19ba654e6a578dc2f705a0470fbc2c19aac9e6f5bf49d488
data/README.md CHANGED
@@ -1,6 +1,20 @@
1
- # RemoteRecord
1
+ ![RemoteRecord: Ready-made remote resource structures.](doc/header.svg)
2
2
 
3
- Ready-made remote resource structures.
3
+ ---
4
+
5
+ ![Remote Record](https://github.com/raisedevs/remote_record/workflows/Remote%20Record/badge.svg)
6
+ [![Gem Version](https://badge.fury.io/rb/remote_record.svg)](https://badge.fury.io/rb/remote_record)
7
+
8
+ Every API speaks a different language. Maybe it's REST, maybe it's SOAP, maybe
9
+ it's GraphQL. Maybe it's got its own Ruby client, or maybe you need to roll your
10
+ own. But what if you could just pretend it existed in your database?
11
+
12
+ RemoteRecord provides a consistent ActiveRecord inspired interface for all of
13
+ your application's APIs. Store remote resources by ID, and RemoteRecord will
14
+ auto-populate instances of your ActiveRecord model with their attributes from
15
+ the API. Whether you're dealing with a user on GitHub, a track on Spotify, a
16
+ place on Google Maps, or a resource on your internal infrastructure, you can use
17
+ RemoteRecord to wrap fetching it.
4
18
 
5
19
  ## Setup
6
20
 
@@ -110,7 +124,126 @@ caching by way of expiry or ETags, I recommend using `faraday-http-cache` for
110
124
  your clients and setting `memoize` to `false`. Remote Record will eventually
111
125
  gain support for caching.
112
126
 
127
+ ### `remote_all` and `remote_where`
128
+
129
+ If you're able to fetch multiple records at once from the API, implement the
130
+ `self.all` method on your remote record class. This should return an array of
131
+ hashes that can be used to initialize a set of references.
132
+
133
+ This can optionally take a block
134
+ for authorization - note that it won't use the auth you've configured and that
135
+ you'll always have to supply that inline. For example:
136
+
137
+ ```ruby
138
+ module RemoteRecord
139
+ module GitHub
140
+ # :nodoc:
141
+ class User < RemoteRecord::Base
142
+ def get
143
+ client.user(remote_resource_id)
144
+ end
145
+
146
+ def self.all
147
+ Octokit::Client.new(access_token: yield).users
148
+ end
149
+
150
+ private
151
+
152
+ def client
153
+ Octokit::Client.new(access_token: authorization)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ ```
159
+
160
+ Now you can call `remote_all` on remote reference classes that use
161
+ `RemoteRecord::GitHub::User`, like this:
162
+
163
+ ```ruby
164
+ GitHub::UserReference.remote_all { GITHUB_PERSONAL_ACCESS_TOKEN }
165
+ ```
166
+
167
+ `remote_where` works in the same way, but with a parameter:
168
+
169
+ ```ruby
170
+ module RemoteRecord
171
+ module GitHub
172
+ # :nodoc:
173
+ class User < RemoteRecord::Base
174
+ def get
175
+ client.user(remote_resource_id)
176
+ end
177
+
178
+ def self.all
179
+ Octokit::Client.new(access_token: yield).users
180
+ end
181
+
182
+ def self.where(query)
183
+ Octokit::Client.new(access_token: yield).search_users(query)
184
+ end
185
+
186
+ private
187
+
188
+ def client
189
+ Octokit::Client.new(access_token: authorization)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ ```
195
+
196
+ Now you can call `remote_where` on remote reference classes that use
197
+ `RemoteRecord::GitHub::User`, like this:
198
+
199
+ ```ruby
200
+ GitHub::UserReference.remote_where('q=tom+repos:%3E42+followers:%3E1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
201
+ ```
202
+
203
+ It's recommended that you include something in `self.where` to filter incoming
204
+ params. Ideally, you want to expose an interface that's as ActiveRecord-like as
205
+ possible, e.g.:
206
+
207
+ ```ruby
208
+ GitHub::UserReference.remote_where(q: 'tom', repos: '>42', followers: '>1000') { GITHUB_PERSONAL_ACCESS_TOKEN }
209
+ ```
210
+
211
+ It's recommended that you write a `Transformer` to do this. Check out
212
+ `RemoteRecord::Transformers::SnakeCase` for an example.
213
+
214
+ ### `initial_attrs`
215
+
216
+ Behind the scenes, `remote_all` initializes references with a set of
217
+ `initial_attrs`. You can do the same! If you've already fetched the data for an
218
+ object, just pass it to `new` for your reference class under the
219
+ `initial_attrs:` keyword parameter, like this:
220
+
221
+ ```ruby
222
+ todo = { id: 1, title: 'Hello world' }
223
+ TodoReference.new(remote_resource_id: todo[:id], initial_attrs: todo)
224
+ ```
225
+
113
226
  ### Forcing a fresh request
114
227
 
115
228
  You might want to force a fresh request in some instances, even if you're using
116
229
  `memoize`. To do this, call `fresh` on a reference, and it'll be repopulated.
230
+
231
+ ### Skip fetching
232
+
233
+ You might not want to make a request on initialize sometimes. In this case, pass
234
+ `fetching: false` when creating or initializing references to make sure the
235
+ resource isn't fetched.
236
+
237
+ When querying for records using ActiveRecord alone, you might want to do so
238
+ within a `no_fetching` context:
239
+
240
+ ```ruby
241
+ TodoReference.no_fetching { |model| model.where(remote_resource_id: 1) }
242
+ ```
243
+
244
+ Any records initialized within a `no_fetching` context won't be requested. It's
245
+ sort of like a `Faraday` cage, pun entirely intended.
246
+
247
+ If you're using `remote_all` or `remote_where` to fetch using your API, that'll
248
+ automatically use this behind the scenes, then set `attrs` to the response
249
+ value.
data/lib/remote_record.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/concern'
4
+ require 'active_support/rescuable'
4
5
  require 'remote_record/base'
5
6
  require 'remote_record/class_lookup'
6
7
  require 'remote_record/config'
@@ -3,14 +3,16 @@
3
3
  module RemoteRecord
4
4
  # Remote record classes should inherit from this class and define #get.
5
5
  class Base
6
+ include ActiveSupport::Rescuable
7
+
6
8
  def self.default_config
7
9
  Config.defaults.merge(remote_record_class: self)
8
10
  end
9
11
 
10
- def initialize(reference, options)
12
+ def initialize(reference, options = default_config, initial_attrs = {})
11
13
  @reference = reference
12
- @options = options.presence || default_config
13
- @attrs = HashWithIndifferentAccess.new
14
+ @options = options
15
+ @attrs = HashWithIndifferentAccess.new(initial_attrs)
14
16
  end
15
17
 
16
18
  def method_missing(method_name, *_args, &_block)
@@ -27,10 +29,22 @@ module RemoteRecord
27
29
  raise NotImplementedError.new, '#get should return a hash of data that represents the remote record.'
28
30
  end
29
31
 
32
+ def self.all
33
+ raise NotImplementedError.new, '#all should return an array of hashes of data that represent remote records.'
34
+ end
35
+
36
+ def self.where(_params)
37
+ raise NotImplementedError.new, '#where should return an array of hashes of data that represent remote records.'
38
+ end
39
+
30
40
  def fetch
31
41
  @attrs.update(get)
32
42
  end
33
43
 
44
+ def attrs=(new_attrs)
45
+ @attrs.update(new_attrs)
46
+ end
47
+
34
48
  private
35
49
 
36
50
  def transform(data)
@@ -9,7 +9,9 @@ module RemoteRecord
9
9
  module Reference
10
10
  extend ActiveSupport::Concern
11
11
 
12
- class_methods do
12
+ class_methods do # rubocop:disable Metrics/BlockLength
13
+ attr_accessor :fetching
14
+
13
15
  def remote_record_class
14
16
  ClassLookup.new(self).remote_record_class(
15
17
  remote_record_config.to_h[:remote_record_class]&.to_s
@@ -23,17 +25,57 @@ module RemoteRecord
23
25
  def remote_record_config
24
26
  Config.new
25
27
  end
28
+
29
+ def fetching
30
+ @fetching = true if @fetching.nil?
31
+ @fetching
32
+ end
33
+
34
+ # Disable fetching for all records initialized in the block.
35
+ def no_fetching
36
+ self.fetching = false
37
+ block_return_value = yield(self)
38
+ self.fetching = true
39
+ block_return_value
40
+ end
41
+
42
+ def remote_all(&authz_proc)
43
+ no_fetching do
44
+ remote_record_class.all(&authz_proc).map do |remote_resource|
45
+ where(remote_resource_id: remote_resource['id']).first_or_initialize.tap do |record|
46
+ record.attrs = remote_resource
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def remote_where(params, &authz_proc)
53
+ no_fetching do
54
+ remote_record_class.where(params, &authz_proc).map do |remote_resource|
55
+ where(remote_resource_id: remote_resource['id']).first_or_initialize.tap do |record|
56
+ record.attrs = remote_resource
57
+ end
58
+ end
59
+ end
60
+ end
26
61
  end
27
62
 
28
63
  # rubocop:disable Metrics/BlockLength
29
64
  included do
30
- attr_accessor :fetching
65
+ include ActiveSupport::Rescuable
66
+ attribute :fetching, :boolean, default: -> { fetching }
67
+ attr_accessor :initial_attrs
31
68
 
32
69
  after_initialize do |reference|
70
+ reference.fetching = false if reference.initial_attrs.present?
33
71
  config = reference.class.remote_record_class.default_config.merge(
34
72
  reference.class.remote_record_config.to_h
35
73
  )
36
74
  reference.instance_variable_set('@remote_record_config', config)
75
+ reference.instance_variable_set('@instance',
76
+ @remote_record_config.remote_record_class.new(
77
+ self, @remote_record_config, reference.initial_attrs.presence || {}
78
+ ))
37
79
  reference.fetch_remote_resource
38
80
  end
39
81
 
@@ -42,20 +84,17 @@ module RemoteRecord
42
84
  def method_missing(method_name, *_args, &_block)
43
85
  fetch_remote_resource unless @remote_record_config.memoize
44
86
 
45
- @instance.public_send(method_name)
87
+ instance.public_send(method_name)
46
88
  end
47
89
 
48
90
  def respond_to_missing?(method_name, _include_private = false)
49
91
  instance.respond_to?(method_name, false)
50
92
  end
51
93
 
52
- def initialize(fetching: true, **args)
53
- @fetching = fetching
54
- super
55
- end
56
-
57
94
  def fetch_remote_resource
58
- instance.fetch if @fetching
95
+ instance.fetch if fetching
96
+ rescue Exception => e # rubocop:disable Lint/RescueException
97
+ rescue_with_handler(e) || raise
59
98
  end
60
99
 
61
100
  def fresh
@@ -65,6 +104,8 @@ module RemoteRecord
65
104
 
66
105
  private
67
106
 
107
+ delegate :attrs=, to: :@instance
108
+
68
109
  def instance
69
110
  @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
70
111
  end
@@ -4,8 +4,11 @@ module RemoteRecord
4
4
  module Transformers
5
5
  # Base transformer class. Inherit from this and implement `#transform`.
6
6
  class Base
7
- def initialize(data)
7
+ def initialize(data, direction = :up)
8
+ raise ArgumentError, 'The direction should be one of :up or :down.' unless %i[up down].include? direction
9
+
8
10
  @data = data
11
+ @direction = direction
9
12
  end
10
13
 
11
14
  def transform
@@ -15,14 +15,19 @@ module RemoteRecord
15
15
  when Array
16
16
  value.map { |v| convert_hash_keys(v) }
17
17
  when Hash
18
- Hash[value.map { |k, v| [underscore_key(k), convert_hash_keys(v)] }]
18
+ Hash[value.map { |k, v| [transform_key(k), convert_hash_keys(v)] }]
19
19
  else
20
20
  value
21
21
  end
22
22
  end
23
23
 
24
- def underscore_key(key)
25
- key.to_s.underscore.to_sym
24
+ def transform_key(key)
25
+ case @direction
26
+ when :up
27
+ key.to_s.underscore.to_sym
28
+ when :down
29
+ key.to_s.camelize(:lower).to_sym
30
+ end
26
31
  end
27
32
  end
28
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RemoteRecord
4
- VERSION = '0.2.0'
4
+ VERSION = '0.7.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: remote_record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Fish
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2020-12-15 00:00:00.000000000 Z
12
+ date: 2021-02-10 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -39,20 +39,6 @@ dependencies:
39
39
  - - ">="
40
40
  - !ruby/object:Gem::Version
41
41
  version: '0'
42
- - !ruby/object:Gem::Dependency
43
- name: database_cleaner
44
- requirement: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - ">="
47
- - !ruby/object:Gem::Version
48
- version: '0'
49
- type: :development
50
- prerelease: false
51
- version_requirements: !ruby/object:Gem::Requirement
52
- requirements:
53
- - - ">="
54
- - !ruby/object:Gem::Version
55
- version: '0'
56
42
  - !ruby/object:Gem::Dependency
57
43
  name: faraday
58
44
  requirement: !ruby/object:Gem::Requirement