remote_record 0.4.0 → 0.8.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 +4 -4
- data/README.md +155 -2
- data/lib/remote_record.rb +1 -0
- data/lib/remote_record/base.rb +17 -3
- data/lib/remote_record/reference.rb +80 -4
- data/lib/remote_record/transformers/base.rb +4 -1
- data/lib/remote_record/transformers/snake_case.rb +8 -3
- data/lib/remote_record/version.rb +1 -1
- metadata +2 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8e59abdc5071a945f3bff2c49d9601fd0a5963fe8476c7df38cae943fe960e67
|
4
|
+
data.tar.gz: 469a8ba9bdbf28f3b5ab8a9ac8513dda3720bfb122d6adfb09639429454d0ee6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7865d6b9f270fb7ffeeb2a8d7cfc7ce298cbfc94f7d1414848dc913b913f71762b1214ab95a9cf1c5e46dbefa9da371fbf25fdf9b75a22aceede815d998f54f7
|
7
|
+
data.tar.gz: 3c472901d7748ec97086a8e9516d8d8edcb10a49cc9a963f5615a146d431f26622c3020cb0367a17f13c5e4df75848c753cde2ac3da0dd06b471bd53dd7e215b
|
data/README.md
CHANGED
@@ -1,6 +1,20 @@
|
|
1
|
-
|
1
|
+

|
2
2
|
|
3
|
-
|
3
|
+
---
|
4
|
+
|
5
|
+

|
6
|
+
[](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
data/lib/remote_record/base.rb
CHANGED
@@ -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
|
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
|
-
|
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 =
|
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
|
-
|
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| [
|
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
|
25
|
-
|
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
|
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
|
+
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-
|
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
|