remote-resource 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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +19 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.rubocop.yml +1156 -0
- data/.travis.yml +17 -0
- data/Gemfile +15 -0
- data/Guardfile +17 -0
- data/LICENSE.txt +21 -0
- data/Procfile.dev +5 -0
- data/README.md +314 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/remote_resource/association_builder.rb +47 -0
- data/lib/remote_resource/attribute_http_client.rb +41 -0
- data/lib/remote_resource/attribute_key.rb +26 -0
- data/lib/remote_resource/attribute_method_attacher.rb +117 -0
- data/lib/remote_resource/attribute_specification.rb +51 -0
- data/lib/remote_resource/attribute_storage_value.rb +63 -0
- data/lib/remote_resource/base/attributes.rb +44 -0
- data/lib/remote_resource/base/base_class_methods.rb +23 -0
- data/lib/remote_resource/base/dsl.rb +27 -0
- data/lib/remote_resource/base/rescue.rb +43 -0
- data/lib/remote_resource/base.rb +35 -0
- data/lib/remote_resource/bridge.rb +174 -0
- data/lib/remote_resource/configuration/logger.rb +24 -0
- data/lib/remote_resource/configuration/lookup_method.rb +24 -0
- data/lib/remote_resource/configuration/storage.rb +24 -0
- data/lib/remote_resource/errors.rb +40 -0
- data/lib/remote_resource/log_subscriber.rb +39 -0
- data/lib/remote_resource/lookup/default.rb +39 -0
- data/lib/remote_resource/notifications.rb +17 -0
- data/lib/remote_resource/railtie.rb +21 -0
- data/lib/remote_resource/scope_evaluator.rb +52 -0
- data/lib/remote_resource/storage/cache_control.rb +120 -0
- data/lib/remote_resource/storage/db_cache.rb +36 -0
- data/lib/remote_resource/storage/db_cache_factory.rb +38 -0
- data/lib/remote_resource/storage/memory.rb +27 -0
- data/lib/remote_resource/storage/null_storage_entry.rb +43 -0
- data/lib/remote_resource/storage/redis.rb +27 -0
- data/lib/remote_resource/storage/serializer.rb +15 -0
- data/lib/remote_resource/storage/serializers/marshal.rb +18 -0
- data/lib/remote_resource/storage/storage_entry.rb +69 -0
- data/lib/remote_resource/version.rb +3 -0
- data/lib/remote_resource.rb +34 -0
- data/remote-resource.gemspec +27 -0
- metadata +175 -0
data/.travis.yml
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
group :test do
|
4
|
+
gem 'codeclimate-test-reporter'
|
5
|
+
gem 'simplecov-console'
|
6
|
+
end
|
7
|
+
|
8
|
+
group :development do
|
9
|
+
gem 'pg'
|
10
|
+
gem 'spirit_hands'
|
11
|
+
gem 'guard-rspec', require: false
|
12
|
+
gem 'terminal-notifier-guard'
|
13
|
+
end
|
14
|
+
|
15
|
+
gemspec
|
data/Guardfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
notification :terminal_notifier
|
2
|
+
ignore %r{.*/flycheck_.*}
|
3
|
+
|
4
|
+
rspec_options = {
|
5
|
+
cmd: 'bundle exec rspec',
|
6
|
+
title: 'RemoteResource Rspec',
|
7
|
+
run_all: {
|
8
|
+
cmd: 'COVERAGE=true bundle exec rspec -f progress',
|
9
|
+
message: 'To view coverage: open coverage/index.html'
|
10
|
+
}
|
11
|
+
}
|
12
|
+
guard :rspec, rspec_options do
|
13
|
+
watch(%r{^spec/.+_spec\.rb$})
|
14
|
+
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
|
15
|
+
watch('spec/spec_helper.rb') { 'spec' }
|
16
|
+
watch(%r{^spec/support/(.+)\.rb$}) { 'spec' }
|
17
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Chris Ewald
|
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.
|
data/Procfile.dev
ADDED
data/README.md
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
# RemoteResource
|
2
|
+
|
3
|
+
[](https://travis-ci.org/mkcode/remote-resource)
|
4
|
+
[](https://codeclimate.com/github/mkcode/remote-resource)
|
5
|
+
[](https://codeclimate.com/github/mkcode/remote-resource/coverage)
|
6
|
+
[](http://inch-ci.org/github/mkcode/remote_resource)
|
7
|
+
|
8
|
+
Add resiliency, speed, and familiarity to the APIs your app relies on. Features:
|
9
|
+
|
10
|
+
* A simple DSL for resource oriented APIs.
|
11
|
+
* Work with foreign APIs in the same way you work with ActiveRecord
|
12
|
+
associations.
|
13
|
+
* Transparently caches API responses for major performance gains.
|
14
|
+
* Don't fail when APIs your app relies on are momentarily down.
|
15
|
+
* Respect your APIs Cache-Control header. Or don't. It's up to you.
|
16
|
+
* Configurable logging and error reporting.
|
17
|
+
* Trivial to add support for your new API client.
|
18
|
+
|
19
|
+
## Getting started
|
20
|
+
|
21
|
+
__RemoteResource__ allows you to easily create `ActiveRecord` style domain
|
22
|
+
objects (or models) that represent a foreign API. These `remote_resources` can
|
23
|
+
be mixed in and associated with other ActiveRecord models in the same way you
|
24
|
+
work with all your other models. Using this pattern and these conventions yields
|
25
|
+
some major performance gains through caching and fast and simple development
|
26
|
+
through familiarity.
|
27
|
+
|
28
|
+
A few steps to get started:
|
29
|
+
|
30
|
+
Create a `remote_resource`, such as:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
# in `app/remote_resources/github_user.rb`
|
34
|
+
class GithubUser < HasRemote::Resource
|
35
|
+
client { Octokit::Client.new }
|
36
|
+
resource { |client, scope| client.user(scope[:github_login]) }
|
37
|
+
|
38
|
+
attribute :id
|
39
|
+
attribute :avatar_url
|
40
|
+
|
41
|
+
...
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
Associate it with your ActiveRecord `User` model:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
# in `app/models/user.rb`
|
49
|
+
class User < ActiveRecord::Base
|
50
|
+
has_remote :github_user, scope: :github_login
|
51
|
+
|
52
|
+
...
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
And you now have an associated remote resource, that you can use just like you
|
57
|
+
local models.
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
user = User.find(1)
|
61
|
+
|
62
|
+
user.github_user.login
|
63
|
+
user.github_user.avatar_url
|
64
|
+
```
|
65
|
+
|
66
|
+
Behind the scene, `has_remote` evaluated the `scope` on user 1 and issued a get
|
67
|
+
request to the GitHub API for the GithubUser with (local) User #1's
|
68
|
+
github_login. The response is cached and future github_user calls will be fast!
|
69
|
+
|
70
|
+
## Installation
|
71
|
+
|
72
|
+
Add this line to your application's Gemfile. __Please note the hyphen__
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
gem 'remote-resource'
|
76
|
+
```
|
77
|
+
|
78
|
+
And then execute:
|
79
|
+
|
80
|
+
$ bundle
|
81
|
+
|
82
|
+
Or install it yourself as:
|
83
|
+
|
84
|
+
$ gem install remote_resource
|
85
|
+
|
86
|
+
## Defining an RemoteResource
|
87
|
+
|
88
|
+
By convention, resource classes are located under app/remote_resources.
|
89
|
+
This folder is automatically added to your Rails eager loaded paths.
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
# In `app/remote_resources/github_user.rb
|
93
|
+
class GithubUserAttributes < RemoteResource::Base
|
94
|
+
client { Octokit::Client.new }
|
95
|
+
|
96
|
+
resource { |client, scope| client.user(scope[:github_login]) }
|
97
|
+
|
98
|
+
rescue_from Octokit::Unauthorized
|
99
|
+
|
100
|
+
attribute :id
|
101
|
+
attribute :avatar_url
|
102
|
+
attribute :url
|
103
|
+
end
|
104
|
+
```
|
105
|
+
|
106
|
+
The are 4 class methods that are available to help define an (API) remote resource. They are:
|
107
|
+
|
108
|
+
* __client__: You return an instance of the web client that the API uses in a block. That block yields the scope. (More on scope later.)
|
109
|
+
|
110
|
+
* __resource__: Supply a block to the resource method that returns a a remote
|
111
|
+
resource. For example, the 'show user' response (GET /user/:github_login)
|
112
|
+
that returns information about a specific user. The return value should
|
113
|
+
respond to `to_hash` in order to be used with attribute. Optionally takes a
|
114
|
+
symbol argument, specifying a name so that it may be looked up later.
|
115
|
+
|
116
|
+
* __attribute__: A single piece of data from the resource (or web) response.
|
117
|
+
This will be mapped to a method later. Optionally takes a second symbol
|
118
|
+
argument referring to a non-default resource (with an argument).
|
119
|
+
|
120
|
+
* __rescue_from__: Works in the same way that ActionContoller's rescue_from
|
121
|
+
works. It takes one or many Error class(es), and either a block of a `:with`
|
122
|
+
option that refers to an instance method on this class. The block and
|
123
|
+
instance method both receive the error and an additional context argument.
|
124
|
+
|
125
|
+
Remote resource allows you to define any instance method you like on it, which
|
126
|
+
may be used by being instantiated itself or from an associated model.
|
127
|
+
|
128
|
+
The following instance methods are available within a RemoteResource::Base class.
|
129
|
+
|
130
|
+
* __client__ - returns the evaluated client block.
|
131
|
+
|
132
|
+
* __resource(resource_name)__ - returns the evaluated resource block for the
|
133
|
+
provided name. An optional argument returns the evaluated resource block with
|
134
|
+
that name.
|
135
|
+
|
136
|
+
* __with_error_handling__ - code executed within a block to this function will
|
137
|
+
have this classes' error handling (from the rescue_from methods) enabled. It
|
138
|
+
takes an optional options Hash which will be sent to error handling block or
|
139
|
+
method which to allow for context specific behavior.
|
140
|
+
|
141
|
+
* __the attributes__ - all of the attributes named in the class method are
|
142
|
+
available as methods in the instance. Attribute methods always return
|
143
|
+
strings.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
# In `app/remote_resources/github_user.rb
|
147
|
+
class GithubUserAttributes < RemoteResource::Base
|
148
|
+
client { Octokit::Client.new }
|
149
|
+
resource { |client, scope| client.user(scope[:github_login]) }
|
150
|
+
attribute :name
|
151
|
+
rescue_from Octokit::Unauthorized, with: :swallow_validate
|
152
|
+
|
153
|
+
def markdown_summary
|
154
|
+
with_error_handling action: :get_markdown do
|
155
|
+
client.markdown "# A big hello to #{name}!!!"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
def handle_fetch(exception, context)
|
162
|
+
raise exception unless context[:action] == :validate
|
163
|
+
end
|
164
|
+
end
|
165
|
+
```
|
166
|
+
|
167
|
+
In the above examples, the `markdown_summary` method returns a string containing
|
168
|
+
a small HTML fragment. The method body uses with evaluated client block which is
|
169
|
+
an Octokit client in this case. Before sending a string to be markdown-ified,
|
170
|
+
the `name` attribute is looked up. This is wrapped inside of a
|
171
|
+
`with_error_handling` block to catch any potential errors.
|
172
|
+
|
173
|
+
The private `handle_fetch` method above is a configured error handler, specified
|
174
|
+
on the above `rescue_from` call. In this case, it re-raises all Unauthorized
|
175
|
+
errors expect for when the action is :validate.
|
176
|
+
|
177
|
+
The above `markdown_summary` method may be used from an associated User as
|
178
|
+
follows. The `handle_fetch` method may not be used because it is private.
|
179
|
+
|
180
|
+
```ruby
|
181
|
+
user = User.find(1)
|
182
|
+
user.github_user.markdown_summary
|
183
|
+
```
|
184
|
+
|
185
|
+
## Instantiating Remote Resources directly
|
186
|
+
|
187
|
+
The above `GithubUser` example may also be instantiated on it's own. The
|
188
|
+
initializer takes the scope argument as an options Hash. In this case, because
|
189
|
+
in our resource block, we use `scope[:github_login]`, we send a `:github_login`
|
190
|
+
option into the constructor. For example:
|
191
|
+
|
192
|
+
```ruby
|
193
|
+
github_user = GithubUser.new(github_login: 'mkcode')
|
194
|
+
```
|
195
|
+
|
196
|
+
Now that we have an instance, we may call any of our custom defined methods on it.
|
197
|
+
|
198
|
+
```ruby
|
199
|
+
github_user.markdown_summary
|
200
|
+
#=> "<h1>A big hello to Chris Ewald!!!</h1>"
|
201
|
+
```
|
202
|
+
|
203
|
+
We also may call any of our defined attributes. Ex:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
github_user.name
|
207
|
+
#=> "Chris Ewald"
|
208
|
+
```
|
209
|
+
|
210
|
+
## The scope
|
211
|
+
|
212
|
+
The scope option evaluates the keys of the Hash on the object specifying it.
|
213
|
+
There are a few different ways to define the scope, but it is always sent into
|
214
|
+
the `client` and `resource` blocks as a symboled key / value Hash. Consider the
|
215
|
+
following lines evaluated inside a User model: `class User < ActiveRecord::Base`
|
216
|
+
|
217
|
+
* `has_remote :github_user, scope: { id: :github_id }` - The scope is a Hash.
|
218
|
+
The :github_id method will be called on the User and sent as the value of the
|
219
|
+
:id key into the RemoteResource. Ex: `scope = { id: 234562 }`
|
220
|
+
|
221
|
+
* `has_remote :github_user, scope: :github_id` - The scope is a single Symbol.
|
222
|
+
Like above, the :github_id method will be called on User, except the value
|
223
|
+
will be sent under a :github_id key. Ex: `scope = { github_id: 234562 }` This
|
224
|
+
is just a shorthand for when the method on the calling object and the scope
|
225
|
+
key are the same.
|
226
|
+
|
227
|
+
* `has_remote :github_user, scope: [:github_id, :access_token]` - The scope is
|
228
|
+
an Array. Both the :github_id and :access_token methods will be called on
|
229
|
+
User and sent in under the same keys.
|
230
|
+
Ex: `scope = { github_id: 234562, access_token: "af98f73qfh37ghf374h34rt9" }`
|
231
|
+
|
232
|
+
Once evaluated, scopes will remain frozen for the lifetime of a RemoteResource
|
233
|
+
instance. They are also used as piece of the cache_key.
|
234
|
+
|
235
|
+
## Is or has
|
236
|
+
|
237
|
+
Two methods are available for your model classes. `has_remote` and
|
238
|
+
`embeds_remote`. They take all the same options and do mostly the same thing;
|
239
|
+
create a method on the calling object, which returns that records associated
|
240
|
+
RemoteResource instance. `embeds_remote` will go one step further and define all
|
241
|
+
of the attribute getter methods on the model class as well. This can be used to
|
242
|
+
create 'flat' domain objects which are backed by values from a remote API. This
|
243
|
+
is largely related to Inhertance vs Composition programming theory which you are
|
244
|
+
welcome to look up on your own time. RemoteResource supports both styles; 'Is'
|
245
|
+
through `embeds_remote` and 'has' through `has_remote`. If unsure, you should
|
246
|
+
prefer to use `has_remote` over `embeds_remote` to create a clear distinction
|
247
|
+
between your local and remote domain.
|
248
|
+
|
249
|
+
## Extending other domain objects
|
250
|
+
|
251
|
+
If you do not use ActiveRecord in your app, you may still use remote_resource by
|
252
|
+
simply extending the Bridge module onto what class you use as your domain. The
|
253
|
+
`has_remote` and `embed_remote` methods will then be available. For example:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
class MyPoro
|
257
|
+
extend RemoteResource::Bridge
|
258
|
+
has_remote :github_user
|
259
|
+
end
|
260
|
+
```
|
261
|
+
|
262
|
+
## Configuration
|
263
|
+
|
264
|
+
In a initializer, like `config/initializers/remote_resource.rb`, you may override the following options:
|
265
|
+
|
266
|
+
```ruby
|
267
|
+
# Setup global storages. For now there is only redis and memory. Default is one
|
268
|
+
# Memory store.
|
269
|
+
|
270
|
+
require 'remote_resource/storage/redis'
|
271
|
+
RemoteResource.storages = [
|
272
|
+
RemoteResource::Storage::Redis.new( Redis.new(url:nil) )
|
273
|
+
]
|
274
|
+
|
275
|
+
# Setup a logger
|
276
|
+
|
277
|
+
RemoteResource.logger = Logger.new(STDOUT)
|
278
|
+
|
279
|
+
# Setup a lookup method. Only default for now, but the `cache_control` option
|
280
|
+
# may be changed to true or false. True will always revalidate. False will never
|
281
|
+
# revalidate. :cache_control respects the Cache-Control header.
|
282
|
+
|
283
|
+
require 'remote_resource/lookup/default'
|
284
|
+
RemoteResource.lookup_method = RemoteResource::Lookup::Default.new(validate: true)
|
285
|
+
```
|
286
|
+
|
287
|
+
## Notifications
|
288
|
+
|
289
|
+
There are 4 ActiveSupport notifications that you may subscribe to, to do in depth profiling of this gem:
|
290
|
+
|
291
|
+
* find.remote_resource
|
292
|
+
* storage_lookup.remote_resource
|
293
|
+
* http_head.remote_resource
|
294
|
+
* http_get.remote_resource
|
295
|
+
|
296
|
+
ActiveSupport::Notifications.subscribe('http_get.remote_resource') do |name, _start, _fin, _id, _payload|
|
297
|
+
puts "HTTP_GET #{name}"
|
298
|
+
end
|
299
|
+
|
300
|
+
## Development
|
301
|
+
|
302
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
303
|
+
|
304
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
305
|
+
|
306
|
+
## Contributing
|
307
|
+
|
308
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/remote_resource. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
309
|
+
|
310
|
+
|
311
|
+
## License
|
312
|
+
|
313
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
314
|
+
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'api_cached_attributes'
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
|
11
|
+
require 'spirit_hands'
|
12
|
+
SpiritHands.app = 'ApiAttrs'
|
13
|
+
|
14
|
+
Pry.start
|
15
|
+
|
data/bin/setup
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
module RemoteResource
|
2
|
+
# The AssociationBuilder class is responsible for defining a method(s) on a
|
3
|
+
# target object that refers to an associated Base class. The body of that
|
4
|
+
# method instantiates an attributes class.
|
5
|
+
class AssociationBuilder
|
6
|
+
attr_reader :base_class, :options
|
7
|
+
|
8
|
+
def initialize(base_class, options = {})
|
9
|
+
@base_class = base_class
|
10
|
+
@options = ensure_options(options)
|
11
|
+
end
|
12
|
+
|
13
|
+
def associated_with(target_class)
|
14
|
+
method_name = @options[:as]
|
15
|
+
set_associated_class(method_name, target_class)
|
16
|
+
define_association_method(method_name, target_class)
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def remote_class_var(method)
|
23
|
+
"@#{method}_remote_class".to_sym
|
24
|
+
end
|
25
|
+
|
26
|
+
def set_associated_class(method, target_class)
|
27
|
+
target_class.instance_variable_set(remote_class_var(method), @base_class)
|
28
|
+
end
|
29
|
+
|
30
|
+
def define_association_method(method_name, target_class)
|
31
|
+
scope = @options[:scope]
|
32
|
+
target_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
33
|
+
def #{method_name}
|
34
|
+
scope_evaluator = RemoteResource::ScopeEvaluator.new(#{scope})
|
35
|
+
evaluated_scope = scope_evaluator.evaluate_on(self)
|
36
|
+
self.class.instance_variable_get(:#{remote_class_var(method_name)})
|
37
|
+
.new(evaluated_scope)
|
38
|
+
end
|
39
|
+
RUBY
|
40
|
+
end
|
41
|
+
|
42
|
+
def ensure_options(options)
|
43
|
+
options[:as] ||= @base_class.underscore
|
44
|
+
options
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|