remote_record 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []