puppet_forge 1.0.6 → 2.0.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.
- data/CHANGELOG.md +23 -0
- data/MAINTAINERS +13 -0
- data/README.md +48 -6
- data/lib/puppet_forge.rb +4 -0
- data/lib/puppet_forge/connection.rb +81 -0
- data/lib/puppet_forge/connection/connection_failure.rb +26 -0
- data/lib/puppet_forge/error.rb +34 -0
- data/lib/{her → puppet_forge}/lazy_accessors.rb +20 -27
- data/lib/{her → puppet_forge}/lazy_relations.rb +28 -9
- data/lib/puppet_forge/middleware/symbolify_json.rb +72 -0
- data/lib/puppet_forge/tar.rb +10 -0
- data/lib/puppet_forge/tar/mini.rb +81 -0
- data/lib/puppet_forge/unpacker.rb +68 -0
- data/lib/puppet_forge/v3.rb +11 -0
- data/lib/puppet_forge/v3/base.rb +106 -73
- data/lib/puppet_forge/v3/base/paginated_collection.rb +23 -14
- data/lib/puppet_forge/v3/metadata.rb +197 -0
- data/lib/puppet_forge/v3/module.rb +2 -1
- data/lib/puppet_forge/v3/release.rb +33 -8
- data/lib/puppet_forge/v3/user.rb +2 -0
- data/lib/puppet_forge/version.rb +1 -1
- data/puppet_forge.gemspec +6 -3
- data/spec/fixtures/v3/modules/puppetlabs-apache.json +21 -1
- data/spec/fixtures/v3/releases/puppetlabs-apache-0.0.1.json +4 -1
- data/spec/integration/forge/v3/module_spec.rb +79 -0
- data/spec/integration/forge/v3/release_spec.rb +75 -0
- data/spec/integration/forge/v3/user_spec.rb +70 -0
- data/spec/spec_helper.rb +15 -8
- data/spec/unit/forge/connection/connection_failure_spec.rb +30 -0
- data/spec/unit/forge/connection_spec.rb +53 -0
- data/spec/unit/{her → forge}/lazy_accessors_spec.rb +20 -13
- data/spec/unit/{her → forge}/lazy_relations_spec.rb +60 -46
- data/spec/unit/forge/middleware/symbolify_json_spec.rb +63 -0
- data/spec/unit/forge/tar/mini_spec.rb +85 -0
- data/spec/unit/forge/tar_spec.rb +9 -0
- data/spec/unit/forge/unpacker_spec.rb +58 -0
- data/spec/unit/forge/v3/base/paginated_collection_spec.rb +68 -46
- data/spec/unit/forge/v3/base_spec.rb +1 -1
- data/spec/unit/forge/v3/metadata_spec.rb +300 -0
- data/spec/unit/forge/v3/module_spec.rb +14 -36
- data/spec/unit/forge/v3/release_spec.rb +9 -30
- data/spec/unit/forge/v3/user_spec.rb +7 -7
- metadata +127 -41
- checksums.yaml +0 -7
- data/lib/puppet_forge/middleware/json_for_her.rb +0 -37
data/CHANGELOG.md
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# Change Log
|
2
|
+
|
3
|
+
Starting with v2.0.0, all notable changes to this project will be documented in this file.
|
4
|
+
This project adheres to [Semantic Versioning](http://semver.org/).i
|
5
|
+
|
6
|
+
## v2.0.0 - 2015-08-13
|
7
|
+
|
8
|
+
### Added
|
9
|
+
|
10
|
+
* PuppetForge::V3::Release can now verify the md5, unpack, and install a release tarball.
|
11
|
+
* PuppetForge::Middleware::SymbolifyJson to change Faraday response hash keys into symbols.
|
12
|
+
* PuppetForge::V3::Metadata to represent a release's metadata as an object.
|
13
|
+
* PuppetForge::Connection to provide Faraday connections.
|
14
|
+
|
15
|
+
### Changed
|
16
|
+
|
17
|
+
* Failed API requests, such as those for a module that doesn't exist, throw a Faraday::ResourceNotFound error.
|
18
|
+
* API requests are sent through Faraday directly rather than through Her.
|
19
|
+
* PuppetForge::V3::Base#where and PuppetForge::V3::Base#all now send an API request immediately and return a paginated collection.
|
20
|
+
|
21
|
+
### Removed
|
22
|
+
|
23
|
+
* Depency on Her (also removes dependency on ActiveSupport).
|
data/MAINTAINERS
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
If you'd like to make a change to this library, you should contact at least one
|
2
|
+
of the maintainers listed below. They will be able to discuss the change with
|
3
|
+
you and decide the next best step. The maintainers will also be notified of pull
|
4
|
+
requests as described in the README. This maintainers file should be kept up to
|
5
|
+
date with the current maintainers of the project.
|
6
|
+
|
7
|
+
If you need to contact the maintainers of this project you should email
|
8
|
+
info@puppetlabs.com.
|
9
|
+
|
10
|
+
Alex Dreyer, Puppet Labs
|
11
|
+
Anderson Mills, Puppet Labs
|
12
|
+
Jesse Scott, Puppet Labs
|
13
|
+
|
data/README.md
CHANGED
@@ -23,8 +23,8 @@ Or install it yourself as:
|
|
23
23
|
|
24
24
|
##Dependencies
|
25
25
|
|
26
|
-
* [
|
27
|
-
* [Typhoeus](https://github.com/typhoeus/typhoeus) ~> 0.
|
26
|
+
* [Faraday]() ~> 0.9.0
|
27
|
+
* [Typhoeus](https://github.com/typhoeus/typhoeus) ~> 0.7.0 (optional)
|
28
28
|
|
29
29
|
Typhoeus will be used as the HTTP adapter if it is available, otherwise
|
30
30
|
Net::HTTP will be used. We recommend using Typhoeus for production-level
|
@@ -78,12 +78,13 @@ resource model are documented on the [Resource Reference][resource_ref] page.
|
|
78
78
|
|
79
79
|
###Basic Interface
|
80
80
|
|
81
|
-
Each of the models uses
|
82
|
-
|
83
|
-
interactions function as intended.
|
81
|
+
Each of the models uses ActiveRecord-like REST functionality to map over the Forge API endpoints.
|
82
|
+
Most simple ActiveRecord-style interactions function as intended.
|
84
83
|
|
85
84
|
Currently, only unauthenticated read-only actions are supported.
|
86
85
|
|
86
|
+
The methods find, where, and all immediately make one API request.
|
87
|
+
|
87
88
|
``` ruby
|
88
89
|
# Find a Resource by Slug
|
89
90
|
PuppetForge::User.find('puppetlabs') # => #<Forge::V3::User(/v3/users/puppetlabs)>
|
@@ -92,10 +93,50 @@ PuppetForge::User.find('puppetlabs') # => #<Forge::V3::User(/v3/users/puppetlabs
|
|
92
93
|
PuppetForge::Module.all # See "Paginated Collections" below for important info about enumerating resource sets.
|
93
94
|
|
94
95
|
# Find Resources with Conditions
|
95
|
-
PuppetForge::Module.where(query: 'apache')
|
96
|
+
PuppetForge::Module.where(query: 'apache') # See "Paginated Collections" below for important info about enumerating resource sets.
|
96
97
|
PuppetForge::Module.where(query: 'apache').first # => #<Forge::V3::Module(/v3/modules/puppetlabs-apache)>
|
97
98
|
```
|
98
99
|
|
100
|
+
For compatibility with older versions of the puppet_forge gem, the following two methods are functionally equivalent.
|
101
|
+
|
102
|
+
``` ruby
|
103
|
+
PuppetForge::Module.where(query: 'apache')
|
104
|
+
PuppetForge::Module.where(query: 'apache').all # This method is deprecated and not recommended
|
105
|
+
```
|
106
|
+
|
107
|
+
####Errors
|
108
|
+
|
109
|
+
All API Requests (whether via find, where, or all) will raise a Faraday::ResourceNotFound error if the request fails.
|
110
|
+
|
111
|
+
|
112
|
+
###Installing a Release
|
113
|
+
|
114
|
+
A release tarball can be downloaded and installed by following the steps below.
|
115
|
+
|
116
|
+
``` ruby
|
117
|
+
release_slug = "puppetlabs-apache-1.6.0"
|
118
|
+
release_tarball = release_slug + ".tar.gz"
|
119
|
+
dest_dir = "/path/to/install/directory"
|
120
|
+
tmp_dir = "/path/to/tmpdir"
|
121
|
+
|
122
|
+
# Fetch Release information from API
|
123
|
+
# @raise Faraday::ResourceNotFound error if the given release does not exist
|
124
|
+
release = PuppetForge::Release.find release_slug
|
125
|
+
|
126
|
+
# Download the Release tarball
|
127
|
+
# @raise PuppetForge::ReleaseNotFound error if the given release does not exist
|
128
|
+
release.download(Pathname(release_tarball))
|
129
|
+
|
130
|
+
# Verify the MD5
|
131
|
+
# @raise PuppetForge::V3::Release::ChecksumMismatch error if the file's md5 does not match the API information
|
132
|
+
release.verify(Pathname(release_tarball))
|
133
|
+
|
134
|
+
# Unpack the files to a given directory
|
135
|
+
# @raise RuntimeError if it fails to extract the contents of the release tarball
|
136
|
+
PuppetForge::Unpacker.unpack(release_tarball, dest_dir, tmp_dir)
|
137
|
+
```
|
138
|
+
|
139
|
+
|
99
140
|
###Paginated Collections
|
100
141
|
|
101
142
|
The Forge API only returns paginated collections as of v3.
|
@@ -170,3 +211,4 @@ to create a free account to add new tickets.
|
|
170
211
|
|
171
212
|
* Pieter van de Bruggen, Puppet Labs
|
172
213
|
* Jesse Scott, Puppet Labs
|
214
|
+
* Austin Blatt, Puppet Labs
|
data/lib/puppet_forge.rb
CHANGED
@@ -8,8 +8,12 @@ module PuppetForge
|
|
8
8
|
|
9
9
|
self.host = 'https://forgeapi.puppetlabs.com'
|
10
10
|
|
11
|
+
require 'puppet_forge/tar'
|
12
|
+
require 'puppet_forge/unpacker'
|
11
13
|
require 'puppet_forge/v3'
|
12
14
|
|
15
|
+
const_set :Metadata, PuppetForge::V3::Metadata
|
16
|
+
|
13
17
|
const_set :User, PuppetForge::V3::User
|
14
18
|
const_set :Module, PuppetForge::V3::Module
|
15
19
|
const_set :Release, PuppetForge::V3::Release
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'puppet_forge/connection/connection_failure'
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday_middleware'
|
5
|
+
require 'puppet_forge/middleware/symbolify_json'
|
6
|
+
|
7
|
+
module PuppetForge
|
8
|
+
# Provide a common mixin for adding a HTTP connection to classes.
|
9
|
+
#
|
10
|
+
# This module provides a common method for creating HTTP connections as well
|
11
|
+
# as reusing a single connection object between multiple classes. Including
|
12
|
+
# classes can invoke #conn to get a reasonably configured HTTP connection.
|
13
|
+
# Connection objects can be passed with the #conn= method.
|
14
|
+
#
|
15
|
+
# @example
|
16
|
+
# class HTTPThing
|
17
|
+
# include PuppetForge::Connection
|
18
|
+
# end
|
19
|
+
# thing = HTTPThing.new
|
20
|
+
# thing.conn = thing.make_connection('https://non-standard-forge.site')
|
21
|
+
#
|
22
|
+
# @api private
|
23
|
+
module Connection
|
24
|
+
|
25
|
+
attr_writer :conn
|
26
|
+
|
27
|
+
USER_AGENT = "#{PuppetForge.user_agent} PuppetForge.gem/#{PuppetForge::VERSION} Faraday/#{Faraday::VERSION} Ruby/#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})".strip
|
28
|
+
|
29
|
+
def self.authorization=(token)
|
30
|
+
@authorization = token
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.authorization
|
34
|
+
@authorization
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Faraday::Connection] An existing Faraday connection if one was
|
38
|
+
# already set, otherwise a new Faraday connection.
|
39
|
+
def conn
|
40
|
+
@conn ||= default_connection
|
41
|
+
end
|
42
|
+
|
43
|
+
def default_connection
|
44
|
+
|
45
|
+
begin
|
46
|
+
# Use Typhoeus if available.
|
47
|
+
Gem::Specification.find_by_name('typhoeus', '~> 0.6')
|
48
|
+
require 'typhoeus/adapters/faraday'
|
49
|
+
adapter = :typhoeus
|
50
|
+
rescue Gem::LoadError
|
51
|
+
adapter = Faraday.default_adapter
|
52
|
+
end
|
53
|
+
|
54
|
+
make_connection(PuppetForge.host, [adapter])
|
55
|
+
end
|
56
|
+
module_function :default_connection
|
57
|
+
|
58
|
+
# Generate a new Faraday connection for the given URL.
|
59
|
+
#
|
60
|
+
# @param url [String] the base URL for this connection
|
61
|
+
# @return [Faraday::Connection]
|
62
|
+
def make_connection(url, adapter_args = nil, opts = {})
|
63
|
+
adapter_args ||= [Faraday.default_adapter]
|
64
|
+
options = { :headers => { :user_agent => USER_AGENT } }.merge(opts)
|
65
|
+
|
66
|
+
if token = PuppetForge::Connection.authorization
|
67
|
+
options[:headers][:authorization] = token
|
68
|
+
end
|
69
|
+
|
70
|
+
Faraday.new(url, options) do |builder|
|
71
|
+
builder.use PuppetForge::Middleware::SymbolifyJson
|
72
|
+
builder.response(:json, :content_type => /\bjson$/)
|
73
|
+
builder.response(:raise_error)
|
74
|
+
builder.use(:connection_failure)
|
75
|
+
|
76
|
+
builder.adapter(*adapter_args)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
module_function :make_connection
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
|
3
|
+
module PuppetForge
|
4
|
+
module Connection
|
5
|
+
# Wrap Faraday connection failures to include the host and optional proxy
|
6
|
+
# in use for the failed connection.
|
7
|
+
class ConnectionFailure < Faraday::Middleware
|
8
|
+
def call(env)
|
9
|
+
@app.call(env)
|
10
|
+
rescue Faraday::ConnectionFailed => e
|
11
|
+
baseurl = env[:url].dup
|
12
|
+
baseurl.path = ''
|
13
|
+
errmsg = "Unable to connect to #{baseurl.to_s}"
|
14
|
+
if proxy = env[:request][:proxy]
|
15
|
+
errmsg << " (using proxy #{proxy.uri.to_s})"
|
16
|
+
end
|
17
|
+
errmsg << ": #{e.message}"
|
18
|
+
m = Faraday::ConnectionFailed.new(errmsg)
|
19
|
+
m.set_backtrace(e.backtrace)
|
20
|
+
raise m
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
Faraday::Middleware.register_middleware(:connection_failure => lambda { PuppetForge::Connection::ConnectionFailure })
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module PuppetForge
|
2
|
+
class Error < RuntimeError
|
3
|
+
attr_accessor :original
|
4
|
+
def initialize(message, original=nil)
|
5
|
+
super(message)
|
6
|
+
@original = original
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class ExecutionFailure < PuppetForge::Error
|
11
|
+
end
|
12
|
+
|
13
|
+
class InvalidPathInPackageError < PuppetForge::Error
|
14
|
+
def initialize(options)
|
15
|
+
@entry_path = options[:entry_path]
|
16
|
+
@directory = options[:directory]
|
17
|
+
super "Attempt to install file into #{@entry_path.inspect} under #{@directory.inspect}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def multiline
|
21
|
+
<<-MSG.strip
|
22
|
+
Could not install package
|
23
|
+
Package attempted to install file into
|
24
|
+
#{@entry_path.inspect} under #{@directory.inspect}.
|
25
|
+
MSG
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ModuleNotFound < PuppetForge::Error
|
30
|
+
end
|
31
|
+
|
32
|
+
class ReleaseNotFound < PuppetForge::Error
|
33
|
+
end
|
34
|
+
end
|
@@ -1,18 +1,12 @@
|
|
1
|
-
|
2
|
-
# @see http://her-rb.org/
|
3
|
-
module Her
|
4
|
-
|
5
|
-
# ActiveRecord-like interface for RESTful models.
|
6
|
-
# @see http://her-rb.org/#usage/activerecord-like-methods
|
7
|
-
module Model; end
|
1
|
+
module PuppetForge
|
8
2
|
|
9
3
|
# When dealing with a remote service, it's reasonably common to receive only
|
10
4
|
# a partial representation of the underlying object, with additional data
|
11
|
-
# available upon request.
|
5
|
+
# available upon request. PuppetForge, by default, provides a convenient interface
|
12
6
|
# for accessing whatever local data is available, but lacks good support for
|
13
7
|
# fleshing out partial representations. In order to build a seamless
|
14
8
|
# interface for both local and remote attriibutes, this module replaces the
|
15
|
-
# default behavior
|
9
|
+
# default behavior with an "updatable" interface.
|
16
10
|
module LazyAccessors
|
17
11
|
|
18
12
|
# Callback for module inclusion.
|
@@ -28,19 +22,25 @@ module Her
|
|
28
22
|
end
|
29
23
|
end
|
30
24
|
|
31
|
-
#
|
25
|
+
# Provide class name for object
|
26
|
+
#
|
27
|
+
def class_name
|
28
|
+
self.class.name.split("::").last.downcase
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override the default #inspect behavior.
|
32
32
|
#
|
33
33
|
# The original behavior actually invokes each attribute accessor, which can
|
34
34
|
# be somewhat problematic when the accessors have been overridden. This
|
35
35
|
# implementation simply reports the contents of the attributes hash.
|
36
36
|
def inspect
|
37
37
|
attrs = attributes.map do |x, y|
|
38
|
-
[ x,
|
38
|
+
[ x, y ].join('=')
|
39
39
|
end
|
40
|
-
"#<#{self.class}(#{
|
40
|
+
"#<#{self.class}(#{uri}) #{attrs.join(' ')}>"
|
41
41
|
end
|
42
42
|
|
43
|
-
# Override the default
|
43
|
+
# Override the default #method_misssing behavior.
|
44
44
|
#
|
45
45
|
# When we receive a {#method_missing} call, one of three things is true:
|
46
46
|
# - the caller is looking up a piece of local data without an accessor
|
@@ -81,22 +81,15 @@ module Her
|
|
81
81
|
return self if @_fetch
|
82
82
|
|
83
83
|
klass = self.class
|
84
|
-
params = { :_method => klass.method_for(:find), :_path => self.request_path }
|
85
84
|
|
86
|
-
klass.request(
|
87
|
-
|
88
|
-
|
89
|
-
parsed.merge!(:_metadata => data[:metadata], :_errors => data[:errors])
|
90
|
-
|
91
|
-
self.send(:initialize, parsed)
|
92
|
-
self.run_callbacks(:find)
|
93
|
-
end
|
85
|
+
response = klass.request("#{self.class_name}s/#{self.slug}")
|
86
|
+
if @_fetch = response.success?
|
87
|
+
self.send(:initialize, response.body)
|
94
88
|
end
|
95
89
|
|
96
90
|
return self
|
97
91
|
end
|
98
92
|
|
99
|
-
|
100
93
|
# A Module subclass for attribute accessors.
|
101
94
|
class AccessorContainer < Module
|
102
95
|
|
@@ -117,19 +110,19 @@ module Her
|
|
117
110
|
# @return [void]
|
118
111
|
def add_attributes(keys)
|
119
112
|
keys.each do |key|
|
120
|
-
next if methods.include?(name =
|
113
|
+
next if methods.include?(name = key)
|
121
114
|
|
122
|
-
define_method(name) do
|
115
|
+
define_method("#{name}") do
|
123
116
|
fetch unless has_attribute?(name)
|
124
117
|
attribute(name)
|
125
118
|
end
|
126
119
|
|
127
|
-
define_method(
|
120
|
+
define_method("#{name}?") do
|
128
121
|
fetch unless has_attribute?(name)
|
129
122
|
has_attribute?(name)
|
130
123
|
end
|
131
124
|
|
132
|
-
define_method(
|
125
|
+
define_method("#{name}=") do |value|
|
133
126
|
fetch unless has_attribute?(name)
|
134
127
|
attributes[name] = value
|
135
128
|
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
module
|
1
|
+
module PuppetForge
|
2
2
|
|
3
3
|
# This module provides convenience accessors for related resources. Related
|
4
4
|
# classes will include {LazyAccessors}, allowing them to transparently fetch
|
@@ -11,9 +11,24 @@ module Her
|
|
11
11
|
# @private
|
12
12
|
def self.included(base)
|
13
13
|
base.extend(self)
|
14
|
-
base.after_initialize { @_lazy = {} }
|
15
14
|
end
|
16
15
|
|
16
|
+
def parent
|
17
|
+
if self.is_a? Class
|
18
|
+
class_name = self.name
|
19
|
+
else
|
20
|
+
class_name = self.class.name
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the name of the version module
|
24
|
+
version = class_name.split("::")[-2]
|
25
|
+
|
26
|
+
if version.nil?
|
27
|
+
raise RuntimeError, "Unable to determine the parent PuppetForge version module"
|
28
|
+
end
|
29
|
+
|
30
|
+
PuppetForge.const_get(version)
|
31
|
+
end
|
17
32
|
# @!macro [attach] lazy
|
18
33
|
# @!method $1
|
19
34
|
# Returns a lazily-loaded $1 proxy. To eagerly load this $1, call
|
@@ -32,16 +47,19 @@ module Her
|
|
32
47
|
# @param name [Symbol] the name of the lazy attribute
|
33
48
|
# @param class_name [#to_s] the lazy relation's class name
|
34
49
|
def lazy(name, class_name = name)
|
35
|
-
parent = self.parent
|
36
50
|
klass = (class_name.is_a?(Class) ? class_name : nil)
|
37
|
-
class_name = "#{class_name}"
|
51
|
+
class_name = "#{class_name}"
|
38
52
|
|
39
53
|
define_method(name) do
|
54
|
+
@_lazy ||= {}
|
55
|
+
|
40
56
|
@_lazy[name] ||= begin
|
57
|
+
|
41
58
|
klass ||= parent.const_get(class_name)
|
42
|
-
|
59
|
+
|
60
|
+
klass.send(:include, PuppetForge::LazyAccessors)
|
43
61
|
fetch unless has_attribute?(name)
|
44
|
-
value =
|
62
|
+
value = attributes[name]
|
45
63
|
klass.new(value) if value
|
46
64
|
end
|
47
65
|
end
|
@@ -69,14 +87,15 @@ module Her
|
|
69
87
|
# @param name [Symbol] the name of the lazy collection attribute
|
70
88
|
# @param class_name [#to_s] the lazy relation's class name
|
71
89
|
def lazy_collection(name, class_name = name)
|
72
|
-
parent = self.parent
|
73
90
|
klass = (class_name.is_a?(Class) ? class_name : nil)
|
74
|
-
class_name = "#{class_name}"
|
91
|
+
class_name = "#{class_name}"
|
75
92
|
|
76
93
|
define_method(name) do
|
94
|
+
@_lazy ||= {}
|
95
|
+
|
77
96
|
@_lazy[name] ||= begin
|
78
97
|
klass ||= parent.const_get(class_name)
|
79
|
-
klass.send(:include,
|
98
|
+
klass.send(:include, PuppetForge::LazyAccessors)
|
80
99
|
fetch unless has_attribute?(name)
|
81
100
|
(attribute(name) || []).map { |x| klass.new(x) }
|
82
101
|
end
|