remote_record 0.3.0 → 0.7.1

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: 7765ca1ef981febac5cb796f25203655e7bc65db3318ecab7e7c9138ea370d4e
4
- data.tar.gz: b5c1ab022c1365735a330badabba5210f0257e01b36f8cdcb6cee27b2608b7a5
3
+ metadata.gz: 95e6b11dd488bf26b81544c37471498737a26598429ba8290e4e9c8e0f6d79a0
4
+ data.tar.gz: cb125226d35be488de6d72651bb1249539d4d2dec487f75e242fc949593b008c
5
5
  SHA512:
6
- metadata.gz: b1d8d8b689a9aeedd624614b538f33f938ee781a9650c8b2f6398536e743d1d2a9cf9560c1ff9c42bfe99e94edb49cb12cf4f80e2d747ace4796cc2281dc22d4
7
- data.tar.gz: dd15eabee7819098bd6633b08935793943b6336a9bf1d24c9a2f15ec40b884d97b946e4c00a9ec98c665e733dbc770c37b91486578921c03e5d7653eae8d5905
6
+ metadata.gz: '08872bedccc7008cbf05129c2e9e791ee73c88bdc222dbaab1ff973bf32b0e24a284690072394c4b6e9a5126f7c0c851e6a2ffbf7ef855909397fbc77d870cbd'
7
+ data.tar.gz: d86cd65b6db39f4191d39b0be6e954b56b118daa02534eb0b8c20e10fcf988d9e07ada0fef2fc418e787b1a112dfc300b31df2f7afde199df88bb3fab68d04ca
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,18 +25,83 @@ 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
+ private
51
+
52
+ def find_or_initialize_all(remote_resources)
53
+ no_fetching do
54
+ pair_remote_resources_with_records(remote_resources) do |unsaved_resources, relation|
55
+ new_resources = unsaved_resources.map do |resource|
56
+ new(remote_resource_id: resource['id']).tap { |record| record.attrs = resource }
57
+ end
58
+ relation.to_a + new_resources
59
+ end
60
+ end
61
+ end
62
+
63
+ def pair_remote_resources_with_records(remote_resources)
64
+ # get resource ids
65
+ ids = remote_resource_ids(remote_resources)
66
+ # get what exists in the database
67
+ relation = where(remote_resource_id: ids)
68
+ # for each record, set its attrs
69
+ relation.map do |record|
70
+ record.attrs = remote_resources.find do |r|
71
+ r['id'].to_s == record.remote_resource_id.to_s
72
+ end
73
+ end
74
+ unsaved_resources = resources_without_persisted_references(remote_resources, relation)
75
+ yield(unsaved_resources, relation)
76
+ end
77
+
78
+ def remote_resource_ids(remote_resources)
79
+ remote_resources.map { |remote_resource| remote_resource['id'] }
80
+ end
81
+
82
+ def resources_without_persisted_references(remote_resources, relation)
83
+ remote_resources.reject do |resource|
84
+ relation.pluck(:remote_resource_id).include? resource['id']
85
+ end
86
+ end
26
87
  end
27
88
 
28
89
  # rubocop:disable Metrics/BlockLength
29
90
  included do
30
- attr_accessor :fetching
91
+ include ActiveSupport::Rescuable
92
+ attribute :fetching, :boolean, default: -> { fetching }
93
+ attr_accessor :initial_attrs
31
94
 
32
95
  after_initialize do |reference|
33
- reference.fetching = true if reference.fetching.nil?
96
+ reference.fetching = false if reference.initial_attrs.present?
34
97
  config = reference.class.remote_record_class.default_config.merge(
35
98
  reference.class.remote_record_config.to_h
36
99
  )
37
100
  reference.instance_variable_set('@remote_record_config', config)
101
+ reference.instance_variable_set('@instance',
102
+ @remote_record_config.remote_record_class.new(
103
+ self, @remote_record_config, reference.initial_attrs.presence || {}
104
+ ))
38
105
  reference.fetch_remote_resource
39
106
  end
40
107
 
@@ -43,7 +110,7 @@ module RemoteRecord
43
110
  def method_missing(method_name, *_args, &_block)
44
111
  fetch_remote_resource unless @remote_record_config.memoize
45
112
 
46
- @instance.public_send(method_name)
113
+ instance.public_send(method_name)
47
114
  end
48
115
 
49
116
  def respond_to_missing?(method_name, _include_private = false)
@@ -52,6 +119,8 @@ module RemoteRecord
52
119
 
53
120
  def fetch_remote_resource
54
121
  instance.fetch if fetching
122
+ rescue Exception => e # rubocop:disable Lint/RescueException
123
+ rescue_with_handler(e) || raise
55
124
  end
56
125
 
57
126
  def fresh
@@ -59,7 +128,7 @@ module RemoteRecord
59
128
  self
60
129
  end
61
130
 
62
- private
131
+ delegate :attrs=, to: :@instance
63
132
 
64
133
  def instance
65
134
  @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.3.0'
4
+ VERSION = '0.7.1'
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.3.0
4
+ version: 0.7.1
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-16 00:00:00.000000000 Z
12
+ date: 2021-02-11 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