remote_record 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a744d661d889b63488c0cae5a44803d4215e384f39d7c7d0671cae12c4e04600
4
+ data.tar.gz: cef3b04a970a6cf4a48a966beb6e935525a72c2697367f81d2bf64a3ce21f9e9
5
+ SHA512:
6
+ metadata.gz: f580bbc02f2ea512f80101a0f4dc7d1b0c293db974c75b820b18e680a48c674a9d266b1542e42eb4988b366a111e30fbf342a59e4e47a3d93ba4658137096e81
7
+ data.tar.gz: '0792f2b3d53c925c348b500b768587eb7de9fea53cf80543f6cb3c4bb945749ad4be33006cb49e82fb17b46c53773e7e64bbd31df28988e19a22e4e8426a0109'
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Simon Fish
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.
@@ -0,0 +1,108 @@
1
+ # RemoteRecord
2
+
3
+ Ready-made remote resource structures.
4
+
5
+ ## Setup
6
+
7
+ ### Jargon
8
+
9
+ **Remote resource** - the resource on the external API that you're trying to
10
+ reach. In this example, we're trying to fetch a GitHub user.
11
+
12
+ **Reference** - your record that points at the remote resource using its ID. In
13
+ this example, these are `GitHub::UserReference`s.
14
+
15
+ **Remote record class** - a class that defines the behavior used to fetch the
16
+ remote resource. In this example, it's `RemoteRecord::GitHub::User`.
17
+
18
+ ### Creating a remote record class
19
+
20
+ A standard RemoteRecord class looks like this. It should have a `get` method,
21
+ which returns a hash of data you'd like to query on the user.
22
+
23
+ `RemoteRecord::Base` exposes private methods for the `remote_resource_id` and
24
+ `authorization` that you configure on the remote reference.
25
+
26
+ ```ruby
27
+ module RemoteRecord
28
+ module GitHub
29
+ # :nodoc:
30
+ class User < RemoteRecord::Base
31
+ def get
32
+ client.user(remote_resource_id)
33
+ end
34
+
35
+ private
36
+
37
+ def client
38
+ Octokit::Client.new(access_token: authorization)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ ```
44
+
45
+ ### Creating a remote reference
46
+
47
+ To start using your remote record class, `include RemoteRecord` into your reference. Now, whenever
48
+ you initialize an instance of your class, it'll be fetched.
49
+
50
+ Calling `remote_record` in addition to this lets you set some options:
51
+
52
+ | Key | Default | Purpose |
53
+ |--------------:|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
54
+ | klass | Inferred from class name | The Remote record class to use for fetching attributes |
55
+ | id_field | `:remote_resource_id` | The field on the reference that contains the remote resource ID |
56
+ | authorization | `''` | An object that can be used by the remote record class to authorize a request. This can be a value, or a proc that returns a value that can be used within the remote record class. |
57
+ | memoize | true | Whether reference instances should memoize the response that populates them |
58
+
59
+ ```ruby
60
+ module GitHub
61
+ # :nodoc:
62
+ class UserReference < ApplicationRecord
63
+ belongs_to :user
64
+ include RemoteRecord
65
+ remote_record do |c|
66
+ c.authorization { |record| record.user.github_auth_tokens.active.first.token }
67
+ # Defaults:
68
+ # c.id_field :remote_resource_id
69
+ # c.klass RemoteRecord::GitHub::User, # Inferred from module and class name
70
+ # c.memoize true
71
+ end
72
+ end
73
+ end
74
+ ```
75
+
76
+ If your API doesn't require authentication at all, you don't even need to
77
+ configure it. So at its best, RemoteRecord can be as lightweight as:
78
+
79
+ ```ruby
80
+ class JsonPlaceholderAPIReference < ApplicationRecord
81
+ include RemoteRecord
82
+ # Falls back to the defaults, so it's equivalent to then calling:
83
+ # remote_record do |c|
84
+ # c.authorization proc { }
85
+ # c.id_field :remote_resource_id
86
+ # c.klass RemoteRecord::JsonPlaceholderAPI, # Inferred from module and class name
87
+ # c.memoize true
88
+ # end
89
+ end
90
+ ```
91
+
92
+ ## Usage
93
+
94
+ Now you've got everything lined up to start using your remote reference.
95
+
96
+ Whenever a `GitHub::UserReference` is initialized, e.g. by calling:
97
+
98
+ ```ruby
99
+ user.github_user_references.first
100
+ ```
101
+
102
+ ...it'll be populated with the GitHub user's data. You can call methods that
103
+ return attributes on the user, like `#login` or `#html_url`.
104
+
105
+ By default, this'll only make a request on initialize. For services that manage
106
+ caching by way of expiry or ETags, I recommend using `faraday-http-cache` for
107
+ your clients and setting `memoize` to `false`. Remote Record will eventually
108
+ gain support for caching.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'remote_record/base'
5
+ require 'remote_record/class_lookup'
6
+ require 'remote_record/config'
7
+ require 'remote_record/dsl'
8
+ require 'remote_record/reference'
9
+ require 'remote_record/version'
10
+
11
+ # Generic interface for resources stored on external services.
12
+ module RemoteRecord
13
+ extend ActiveSupport::Concern
14
+ included do
15
+ include Reference
16
+ include DSL
17
+ end
18
+
19
+ class RecordClassNotFound < StandardError; end
20
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ # Remote record classes should inherit from this class and define #get.
5
+ class Base
6
+ def self.default_config
7
+ Config.defaults.merge(remote_record_class: self)
8
+ end
9
+
10
+ def initialize(reference, options)
11
+ @reference = reference
12
+ @options = options.presence || default_config
13
+ @attrs = HashWithIndifferentAccess.new
14
+ end
15
+
16
+ def method_missing(method_name, *_args, &_block)
17
+ @attrs.fetch(method_name)
18
+ rescue KeyError
19
+ super
20
+ end
21
+
22
+ def respond_to_missing?(method_name, _include_private = false)
23
+ @attrs.key?(method_name)
24
+ end
25
+
26
+ def get
27
+ raise NotImplementedError.new, '#get should return a hash of data that represents the remote record.'
28
+ end
29
+
30
+ def fetch
31
+ @attrs.update(get)
32
+ end
33
+
34
+ private
35
+
36
+ def authorization
37
+ authz = @options.authorization
38
+ authz.respond_to?(:call) ? authz.call(@reference, @options) : authz
39
+ end
40
+
41
+ def remote_resource_id
42
+ @reference.send(@options.id_field)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ # Looks up the class name to use to define the remote record's behavior.
5
+ class ClassLookup
6
+ def initialize(klass)
7
+ @klass = klass
8
+ end
9
+
10
+ def remote_record_class(class_name_override = nil)
11
+ class_name = (class_name_override || infer_remote_record_class_name)
12
+ class_name.constantize
13
+ rescue NameError
14
+ raise RemoteRecord::RecordClassNotFound, "#{class_name} couldn't be found." \
15
+ "#{' Perhaps you need to define `remote_record_class`?' unless class_name_override}"
16
+ end
17
+
18
+ private
19
+
20
+ def infer_remote_record_class_name
21
+ "RemoteRecord::#{@klass.to_s.delete_suffix('Reference')}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configuration specific to a single remote record class.
4
+ module RemoteRecord
5
+ # Configuration propagated between remote records and their references. When a
6
+ # new remote reference is initialized, its config is constructed using the
7
+ # defaults of the remote record class and the overrides set when
8
+ # `remote_record` is called.
9
+ class Config
10
+ OPTIONS = %i[remote_record_class authorization memoize id_field].freeze
11
+
12
+ def initialize(**options)
13
+ @options = options
14
+ end
15
+
16
+ def self.defaults
17
+ new(
18
+ authorization: '',
19
+ memoize: true,
20
+ id_field: :remote_resource_id
21
+ )
22
+ end
23
+
24
+ OPTIONS.each do |option|
25
+ define_method(option) do |new_value = nil, &block|
26
+ block_attr_accessor(option, new_value, &block)
27
+ end
28
+ end
29
+
30
+ # Returns the attribute value if called without args or a block. Otherwise,
31
+ # sets the attribute to the block or value passed.
32
+ def block_attr_accessor(attribute, new_value = nil, &block)
33
+ return @options.fetch(attribute) unless block_given? || !new_value.nil?
34
+
35
+ @options[attribute] = block_given? ? block : new_value
36
+ self
37
+ end
38
+
39
+ def to_h
40
+ @options
41
+ end
42
+
43
+ def merge(**overrides)
44
+ @options.merge!(**overrides)
45
+ self
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ # A DSL that's helpful for configuring remote references. To configure a
5
+ # remote reference, `include RemoteRecord`, then call `remote_record` to
6
+ # configure the module.
7
+ # See RemoteRecord::Config#defaults for the default configuration.
8
+ module DSL
9
+ extend ActiveSupport::Concern
10
+ class_methods do
11
+ def remote_record(remote_record_class: nil)
12
+ klass = RemoteRecord::ClassLookup.new(self).remote_record_class(remote_record_class)
13
+ config = RemoteRecord::Config.new(remote_record_class: klass)
14
+ config = yield(config) if block_given?
15
+ DSLPrivate.validate_config(config)
16
+ define_singleton_method(:remote_record_config) { config }
17
+ end
18
+ end
19
+ end
20
+
21
+ # Methods private to the DSL module.
22
+ module DSLPrivate
23
+ class << self
24
+ def responds_to_get?(klass)
25
+ klass.instance_methods(false).include? :get
26
+ end
27
+
28
+ def validate_config(config)
29
+ klass = RemoteRecord::ClassLookup.new(self.class.to_s)
30
+ .remote_record_class(config.to_h[:remote_record_class].to_s)
31
+ raise NotImplementedError.new, 'The remote record does not implement #get.' unless responds_to_get?(klass)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ # Core structure of a reference. A reference populates itself with all the
5
+ # data for a remote record using behavior defined by its associated remote
6
+ # record class (a descendant of RemoteRecord::Base). This is done on
7
+ # initialize by calling #get on an instance of the remote record class. These
8
+ # attributes are then accessible on the reference thanks to #method_missing.
9
+ module Reference
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ def remote_record_class
14
+ ClassLookup.new(self).remote_record_class(
15
+ remote_record_config.to_h[:remote_record_class]&.to_s
16
+ )
17
+ end
18
+
19
+ # Default to an empty config, which falls back to the remote record
20
+ # class's default config and leaves the remote record class to be inferred
21
+ # from the reference class name
22
+ # This method is overridden using RemoteRecord::DSL#remote_record.
23
+ def remote_record_config
24
+ Config.new
25
+ end
26
+ end
27
+
28
+ included do
29
+ after_initialize do |reference|
30
+ config = reference.class.remote_record_class.default_config.merge(
31
+ reference.class.remote_record_config.to_h
32
+ )
33
+ reference.instance_variable_set('@remote_record_config', config)
34
+ reference.fetch_remote_resource
35
+ end
36
+
37
+ # This doesn't call `super` because it delegates to @instance in all
38
+ # cases.
39
+ def method_missing(method_name, *_args, &_block)
40
+ fetch_remote_resource unless @remote_record_config.memoize
41
+
42
+ @instance.public_send(method_name)
43
+ end
44
+
45
+ def respond_to_missing?(method_name, _include_private = false)
46
+ instance.respond_to?(method_name, false)
47
+ end
48
+
49
+ def initialize(**args)
50
+ @attrs = HashWithIndifferentAccess.new
51
+ super
52
+ end
53
+
54
+ def fetch_remote_resource
55
+ instance.fetch
56
+ end
57
+
58
+ private
59
+
60
+ def instance
61
+ @instance ||= @remote_record_config.remote_record_class.new(self, @remote_record_config)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemoteRecord
4
+ VERSION = '0.1.0'
5
+ end
metadata ADDED
@@ -0,0 +1,224 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: remote_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Fish
8
+ - John Britton
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2020-12-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: activesupport
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
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
+ - !ruby/object:Gem::Dependency
57
+ name: faraday
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: faraday_middleware
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rake
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '12.0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '12.0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: rspec
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '3.0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '3.0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rubocop
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '1.4'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '1.4'
126
+ - !ruby/object:Gem::Dependency
127
+ name: rubocop-packaging
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - "~>"
131
+ - !ruby/object:Gem::Version
132
+ version: 0.5.1
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - "~>"
138
+ - !ruby/object:Gem::Version
139
+ version: 0.5.1
140
+ - !ruby/object:Gem::Dependency
141
+ name: sqlite3
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ type: :development
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ - !ruby/object:Gem::Dependency
155
+ name: vcr
156
+ requirement: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ type: :development
162
+ prerelease: false
163
+ version_requirements: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ - !ruby/object:Gem::Dependency
169
+ name: webmock
170
+ requirement: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ type: :development
176
+ prerelease: false
177
+ version_requirements: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ description: Allows creating local instances of objects stored on remote services.
183
+ email:
184
+ - si@mon.fish
185
+ - public@johndbritton.com
186
+ executables: []
187
+ extensions: []
188
+ extra_rdoc_files: []
189
+ files:
190
+ - LICENSE.txt
191
+ - README.md
192
+ - lib/remote_record.rb
193
+ - lib/remote_record/base.rb
194
+ - lib/remote_record/class_lookup.rb
195
+ - lib/remote_record/config.rb
196
+ - lib/remote_record/dsl.rb
197
+ - lib/remote_record/reference.rb
198
+ - lib/remote_record/version.rb
199
+ homepage: https://github.com/raisedevs/remote_record
200
+ licenses:
201
+ - MIT
202
+ metadata:
203
+ homepage_uri: https://github.com/raisedevs/remote_record
204
+ source_code_uri: https://github.com/raisedevs/remote_record
205
+ post_install_message:
206
+ rdoc_options: []
207
+ require_paths:
208
+ - lib
209
+ required_ruby_version: !ruby/object:Gem::Requirement
210
+ requirements:
211
+ - - ">="
212
+ - !ruby/object:Gem::Version
213
+ version: 2.5.0
214
+ required_rubygems_version: !ruby/object:Gem::Requirement
215
+ requirements:
216
+ - - ">="
217
+ - !ruby/object:Gem::Version
218
+ version: '0'
219
+ requirements: []
220
+ rubygems_version: 3.1.4
221
+ signing_key:
222
+ specification_version: 4
223
+ summary: Ready-made remote resource structures.
224
+ test_files: []