remote_record 0.4.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 285bbd2e1d94c57c73399a83c336404b2eb190e4b08e14287671152cf36dc866
4
- data.tar.gz: 9b3ce895695588e43516e86a9498ed64d33ad76771afa13e739835dc12175abb
3
+ metadata.gz: 8e59abdc5071a945f3bff2c49d9601fd0a5963fe8476c7df38cae943fe960e67
4
+ data.tar.gz: 469a8ba9bdbf28f3b5ab8a9ac8513dda3720bfb122d6adfb09639429454d0ee6
5
5
  SHA512:
6
- metadata.gz: d9105b2bd550f9553e2c04c8caa2a1ca1933628e51c3ccf318bc19c62721be4302d1ca490f29ab194dee971642ebb045f102b8e5a8179e3362cb630c7ae8db0a
7
- data.tar.gz: 24966ceeb7d708fedaf6413e98496c485f77314d1837ca15cc5d7db9cd1d2e6ed0f3c06d50f7f709cc77f430e36b3f66d18e09e3970b246813b3e2e1c057d528
6
+ metadata.gz: 7865d6b9f270fb7ffeeb2a8d7cfc7ce298cbfc94f7d1414848dc913b913f71762b1214ab95a9cf1c5e46dbefa9da371fbf25fdf9b75a22aceede815d998f54f7
7
+ data.tar.gz: 3c472901d7748ec97086a8e9516d8d8edcb10a49cc9a963f5615a146d431f26622c3020cb0367a17f13c5e4df75848c753cde2ac3da0dd06b471bd53dd7e215b
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,146 @@ 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.
250
+
251
+ ### Finding a record without having its canonical ID
252
+
253
+ On many platforms, you might find yourself searching for users by email or
254
+ username. Those aren't canonical IDs - they could change. But searching for them
255
+ by either of those things is a safe bet as a user, nine times out of ten.
256
+
257
+ Similarly, you (or your users) might not always have a remote resource's ID
258
+ upfront. You might, however, have something unique enough to discern it from
259
+ other records, like a user-facing ID. A good example is a pull request reference
260
+ on GitHub - using the repo name, owner's username, and pull request ID, you can
261
+ find a pull request.
262
+
263
+ Of course, that shouldn't be your canonical source, because two of those things
264
+ could change. You could change your username, and you could rename the repo. But
265
+ it's useful to be able to search by those things, right?
266
+
267
+ Implement `find_by` on your remote_record class, and RemoteRecord will use it.
268
+ If you don't, RemoteRecord will fall back to `remote_where`. This takes the same
269
+ params as other class-level RemoteRecord methods, including an auth proc.
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,18 +25,90 @@ 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
+ find_or_initialize_all(remote_record_class.all(&authz_proc))
44
+ end
45
+
46
+ def remote_where(params, &authz_proc)
47
+ find_or_initialize_all(remote_record_class.where(params, &authz_proc))
48
+ end
49
+
50
+ def remote_find_by(params, &authz_proc)
51
+ return remote_where(params, &authz_proc).first unless remote_record_class.respond_to?(:find_by)
52
+
53
+ resource = remote_record_class.find_by(params, &authz_proc)
54
+ new(remote_resource_id: resource['id'], initial_attrs: resource)
55
+ end
56
+
57
+ private
58
+
59
+ def find_or_initialize_all(remote_resources)
60
+ no_fetching do
61
+ pair_remote_resources_with_records(remote_resources) do |unsaved_resources, relation|
62
+ new_resources = unsaved_resources.map do |resource|
63
+ new(remote_resource_id: resource['id']).tap { |record| record.attrs = resource }
64
+ end
65
+ relation.to_a + new_resources
66
+ end
67
+ end
68
+ end
69
+
70
+ def pair_remote_resources_with_records(remote_resources)
71
+ # get resource ids
72
+ ids = remote_resource_ids(remote_resources)
73
+ # get what exists in the database
74
+ relation = where(remote_resource_id: ids)
75
+ # for each record, set its attrs
76
+ relation.map do |record|
77
+ record.attrs = remote_resources.find do |r|
78
+ r['id'].to_s == record.remote_resource_id.to_s
79
+ end
80
+ end
81
+ unsaved_resources = resources_without_persisted_references(remote_resources, relation)
82
+ yield(unsaved_resources, relation)
83
+ end
84
+
85
+ def remote_resource_ids(remote_resources)
86
+ remote_resources.map { |remote_resource| remote_resource['id'] }
87
+ end
88
+
89
+ def resources_without_persisted_references(remote_resources, relation)
90
+ remote_resources.reject do |resource|
91
+ relation.pluck(:remote_resource_id).include? resource['id']
92
+ end
93
+ end
26
94
  end
27
95
 
28
96
  # rubocop:disable Metrics/BlockLength
29
97
  included do
30
- attr_accessor :fetching
98
+ include ActiveSupport::Rescuable
99
+ attribute :fetching, :boolean, default: -> { fetching }
100
+ attr_accessor :initial_attrs
31
101
 
32
102
  after_initialize do |reference|
33
- reference.fetching = true if reference.fetching.nil?
103
+ reference.fetching = false if reference.initial_attrs.present?
34
104
  config = reference.class.remote_record_class.default_config.merge(
35
105
  reference.class.remote_record_config.to_h
36
106
  )
37
107
  reference.instance_variable_set('@remote_record_config', config)
108
+ reference.instance_variable_set('@instance',
109
+ @remote_record_config.remote_record_class.new(
110
+ self, @remote_record_config, reference.initial_attrs.presence || {}
111
+ ))
38
112
  reference.fetch_remote_resource
39
113
  end
40
114
 
@@ -52,6 +126,8 @@ module RemoteRecord
52
126
 
53
127
  def fetch_remote_resource
54
128
  instance.fetch if fetching
129
+ rescue Exception => e # rubocop:disable Lint/RescueException
130
+ rescue_with_handler(e) || raise
55
131
  end
56
132
 
57
133
  def fresh
@@ -59,7 +135,7 @@ module RemoteRecord
59
135
  self
60
136
  end
61
137
 
62
- private
138
+ delegate :attrs=, to: :@instance
63
139
 
64
140
  def instance
65
141
  @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
@@ -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.4.0'
4
+ VERSION = '0.8.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.4.0
4
+ version: 0.8.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: 2021-01-12 00:00:00.000000000 Z
12
+ date: 2021-02-24 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