occi-api 4.0.0.alpha.1 → 4.0.0.alpha.2
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/occi-api.rb +0 -1
- data/lib/occi/api/client/{http/authn_utils.rb → authn_utils.rb} +0 -0
- data/lib/occi/api/client/client_base.rb +56 -90
- data/lib/occi/api/client/client_http.rb +13 -3
- data/lib/occi/api/client/http/authn_plugins/base.rb +1 -1
- data/lib/occi/api/client/http/authn_plugins/keystone.rb +52 -9
- data/lib/occi/api/client/http/httparty_fix.rb +8 -32
- data/lib/occi/api/version.rb +1 -1
- data/occi-api.gemspec +1 -0
- data/spec/cassettes/Occi_Api_Client_ClientHttp/using_media_type_text_plain/raises_an_error_when_looking_for_a_non-existent_mixin_type.yml +266 -0
- data/spec/cassettes/Occi_Api_Client_ClientHttp/using_media_type_text_plain/returns_nil_when_looking_for_a_non-existent_mixin.yml +266 -0
- data/spec/cassettes/Occi_Api_Client_ClientHttp/using_media_type_text_plain/returns_nil_when_looking_for_a_non-existent_mixin_of_a_specific_type.yml +266 -0
- data/spec/occi/api/client/{http/authn_utils_spec.rb → authn_utils_spec.rb} +1 -1
- data/spec/occi/api/client/client_http_spec.rb +20 -3
- data/spec/occi/api/client/{http/rocci-cred-cert.pem → rocci-cred-cert.pem} +0 -0
- data/spec/occi/api/client/{http/rocci-cred-key-jruby.pem → rocci-cred-key-jruby.pem} +0 -0
- data/spec/occi/api/client/{http/rocci-cred-key.pem → rocci-cred-key.pem} +0 -0
- data/spec/occi/api/client/{http/rocci-cred.p12 → rocci-cred.p12} +0 -0
- metadata +13 -9
data/lib/occi-api.rb
CHANGED
@@ -8,7 +8,6 @@ module Occi::Api; end
|
|
8
8
|
require 'occi/api/version'
|
9
9
|
require 'occi/api/client/client_base'
|
10
10
|
require 'occi/api/client/errors'
|
11
|
-
require 'occi/api/client/http/authn_plugins'
|
12
11
|
require 'occi/api/client/client_http'
|
13
12
|
require 'occi/api/client/client_amqp'
|
14
13
|
require 'occi/api/dsl'
|
File without changes
|
@@ -196,17 +196,15 @@ module Occi
|
|
196
196
|
# @param [String] resource name or resource identifier
|
197
197
|
# @return [Occi::Core::Resource] new resource instance
|
198
198
|
def get_resource(resource_type)
|
199
|
-
|
200
199
|
Occi::Log.debug("Instantiating #{resource_type} ...")
|
201
200
|
|
202
|
-
type_id =
|
203
|
-
if @model.get_by_id resource_type
|
201
|
+
type_id = if @model.get_by_id resource_type
|
204
202
|
# we got a resource type identifier
|
205
|
-
|
203
|
+
resource_type
|
206
204
|
else
|
207
205
|
# we got a resource type name
|
208
206
|
type_ids = @model.kinds.select { |kind| kind.term == resource_type }
|
209
|
-
|
207
|
+
type_ids.first.type_identifier if type_ids.any?
|
210
208
|
end
|
211
209
|
|
212
210
|
raise "Unknown resource type! [#{resource_type}]" unless type_id
|
@@ -320,97 +318,72 @@ module Occi
|
|
320
318
|
# @param [Boolean] should we describe the mixin or return its link?
|
321
319
|
# @return [String, Occi::Collection, nil] link, mixin description or nothing found
|
322
320
|
def find_mixin(name, type = nil, describe = false)
|
323
|
-
|
324
321
|
Occi::Log.debug("Looking for mixin #{name} + #{type} + #{describe}")
|
325
|
-
|
326
|
-
# is type valid?
|
327
322
|
raise "Unknown mixin type! [#{type}]" if type && !@mixins.has_key?(type.to_sym)
|
328
323
|
|
329
324
|
# TODO: extend this code to support multiple matches and regex filters
|
330
325
|
# should we look for links or descriptions?
|
331
|
-
|
332
|
-
# we are looking for descriptions
|
333
|
-
find_mixin_describe name, type
|
334
|
-
else
|
335
|
-
# we are looking for links
|
336
|
-
find_mixin_list name, type
|
337
|
-
end
|
326
|
+
describe ? describe_mixin(name, type) : list_mixin(name, type)
|
338
327
|
end
|
339
328
|
|
340
329
|
# Looks up a mixin using its name and, optionally, a type as well.
|
341
330
|
# Will return mixin's full description.
|
342
331
|
#
|
343
332
|
# @example
|
344
|
-
# client.
|
333
|
+
# client.describe_mixin "debian6"
|
345
334
|
# # => #<Occi::Collection>
|
346
|
-
# client.
|
335
|
+
# client.describe_mixin "debian6", "os_tpl"
|
347
336
|
# # => #<Occi::Collection>
|
348
|
-
# client.
|
337
|
+
# client.describe_mixin "large", "resource_tpl"
|
349
338
|
# # => #<Occi::Collection>
|
350
|
-
# client.
|
339
|
+
# client.describe_mixin "debian6", "resource_tpl" # => nil
|
351
340
|
#
|
352
341
|
# @param [String] name of the mixin
|
353
342
|
# @param [String] type of the mixin
|
354
343
|
# @return [Occi::Collection, nil] mixin description or nothing found
|
355
|
-
def
|
356
|
-
found_ary =
|
357
|
-
|
358
|
-
|
359
|
-
# get the first match from either os_tpls or resource_tpls
|
360
|
-
case type
|
361
|
-
when "os_tpl"
|
362
|
-
found_ary = get_os_templates.select { |mixin| mixin.term == name }
|
363
|
-
when "resource_tpl"
|
364
|
-
found_ary = get_resource_templates.select { |template| template.term == name }
|
365
|
-
else
|
366
|
-
# TODO: should raise an Error?
|
367
|
-
end
|
368
|
-
else
|
369
|
-
# try in os_tpls first
|
370
|
-
found_ary = get_os_templates.select { |os| os.term == name }
|
344
|
+
def describe_mixin(name, type = nil)
|
345
|
+
found_ary = type ? describe_mixin_w_type(name, type) : describe_mixin_wo_type(name)
|
346
|
+
found_ary.any? ? found_ary.first : nil
|
347
|
+
end
|
371
348
|
|
372
|
-
|
349
|
+
#
|
350
|
+
#
|
351
|
+
#
|
352
|
+
def describe_mixin_w_type(name, type)
|
353
|
+
return unless %w( os_tpl resource_tpl ).include? type.to_s
|
354
|
+
send("get_#{type.to_s}s".to_sym).select { |mixin| mixin.term == name }
|
355
|
+
end
|
373
356
|
|
374
|
-
|
375
|
-
|
376
|
-
|
357
|
+
#
|
358
|
+
#
|
359
|
+
#
|
360
|
+
def describe_mixin_wo_type(name)
|
361
|
+
%w( os_tpl resource_tpl ).each do |type|
|
362
|
+
found = send("get_#{type}s".to_sym).select { |mixin| mixin.term == name }
|
363
|
+
return found if found.any?
|
377
364
|
end
|
378
365
|
|
379
|
-
|
366
|
+
[]
|
380
367
|
end
|
381
368
|
|
382
369
|
# Looks up a mixin using its name and, optionally, a type as well.
|
383
370
|
# Will return mixin's full location.
|
384
371
|
#
|
385
372
|
# @example
|
386
|
-
# client.
|
373
|
+
# client.list_mixin "debian6"
|
387
374
|
# # => "http://my.occi.service/occi/infrastructure/os_tpl#debian6"
|
388
|
-
# client.
|
375
|
+
# client.list_mixin "debian6", "os_tpl"
|
389
376
|
# # => "http://my.occi.service/occi/infrastructure/os_tpl#debian6"
|
390
|
-
# client.
|
377
|
+
# client.list_mixin "large", "resource_tpl"
|
391
378
|
# # => "http://my.occi.service/occi/infrastructure/resource_tpl#large"
|
392
|
-
# client.
|
379
|
+
# client.list_mixin "debian6", "resource_tpl" # => nil
|
393
380
|
#
|
394
381
|
# @param [String] name of the mixin
|
395
382
|
# @param [String] type of the mixin
|
396
383
|
# @return [String, nil] link or nothing found
|
397
|
-
def
|
398
|
-
|
399
|
-
mxns =
|
400
|
-
name_rev = "##{name}".reverse
|
401
|
-
|
402
|
-
if type
|
403
|
-
# return the first match with the selected type
|
404
|
-
mxns = @mixins[type.to_sym].select {
|
405
|
-
|mixin| mixin.to_s.reverse.start_with? name_rev
|
406
|
-
}
|
407
|
-
else
|
408
|
-
# there is no type preference, return first global match
|
409
|
-
mxns = @mixins.flatten(2).select {
|
410
|
-
|mixin| mixin.to_s.reverse.start_with? name_rev
|
411
|
-
}
|
412
|
-
end
|
413
|
-
|
384
|
+
def list_mixin(name, type = nil)
|
385
|
+
mxns = type ? @mixins[type.to_sym] : @mixins.flatten(2)
|
386
|
+
mxns = mxns.select { |mixin| mixin.to_s.reverse.start_with? "##{name}".reverse }
|
414
387
|
mxns.any? ? mxns.first : nil
|
415
388
|
end
|
416
389
|
|
@@ -433,18 +406,11 @@ module Occi
|
|
433
406
|
if type
|
434
407
|
# is type valid?
|
435
408
|
raise "Unknown mixin type! #{type}" unless @mixins.has_key? type.to_sym
|
436
|
-
|
437
|
-
# return mixin of the selected type
|
438
409
|
@mixins[type.to_sym]
|
439
410
|
else
|
440
411
|
# we did not get a type, return all mixins
|
441
412
|
mixins = []
|
442
|
-
|
443
|
-
# flatten the hash and remove its keys
|
444
|
-
get_mixin_types.each do |ltype|
|
445
|
-
mixins.concat @mixins[ltype.to_sym]
|
446
|
-
end
|
447
|
-
|
413
|
+
get_mixin_types.each { |ltype| mixins.concat @mixins[ltype.to_sym] }
|
448
414
|
mixins
|
449
415
|
end
|
450
416
|
end
|
@@ -472,7 +438,7 @@ module Occi
|
|
472
438
|
identifiers = []
|
473
439
|
|
474
440
|
get_mixin_types.each do |mixin_type|
|
475
|
-
identifiers <<
|
441
|
+
identifiers << "http://schemas.ogf.org/occi/infrastructure##{mixin_type}"
|
476
442
|
end
|
477
443
|
|
478
444
|
identifiers
|
@@ -487,6 +453,7 @@ module Occi
|
|
487
453
|
def get_os_templates
|
488
454
|
@model.get.mixins.select { |mixin| mixin.related.select { |rel| rel.end_with? 'os_tpl' }.any? }
|
489
455
|
end
|
456
|
+
alias_method :get_os_tpls, :get_os_templates
|
490
457
|
|
491
458
|
# Retrieves available resource_tpls from the model.
|
492
459
|
#
|
@@ -497,6 +464,7 @@ module Occi
|
|
497
464
|
def get_resource_templates
|
498
465
|
@model.get.mixins.select { |mixin| mixin.related.select { |rel| rel.end_with? 'resource_tpl' }.any? }
|
499
466
|
end
|
467
|
+
alias_method :get_resource_tpls, :get_resource_templates
|
500
468
|
|
501
469
|
# Creates a link of a specified kind and binds it to the given resource.
|
502
470
|
#
|
@@ -611,7 +579,7 @@ module Occi
|
|
611
579
|
#
|
612
580
|
# @param [Hash] logger options
|
613
581
|
def set_logger(log_options)
|
614
|
-
|
582
|
+
unless log_options[:logger] && log_options[:logger].kind_of?(Occi::Log)
|
615
583
|
@logger = Occi::Log.new(log_options[:out])
|
616
584
|
@logger.level = log_options[:level]
|
617
585
|
end
|
@@ -626,7 +594,7 @@ module Occi
|
|
626
594
|
# @param [String] endpoint URI in a non-canonical string
|
627
595
|
# @return [String] canonical endpoint URI in a string, with a trailing slash
|
628
596
|
def set_endpoint(endpoint)
|
629
|
-
raise 'Endpoint not a valid URI'
|
597
|
+
raise 'Endpoint not a valid URI' unless (endpoint =~ URI::ABS_URI)
|
630
598
|
@endpoint = endpoint.chomp('/') + '/'
|
631
599
|
end
|
632
600
|
|
@@ -644,7 +612,7 @@ module Occi
|
|
644
612
|
|
645
613
|
@mixins = {
|
646
614
|
:os_tpl => get_os_tpl_mixins_ary,
|
647
|
-
:resource_tpl =>
|
615
|
+
:resource_tpl => get_resource_tpl_mixins_ary
|
648
616
|
}
|
649
617
|
|
650
618
|
@model
|
@@ -654,32 +622,30 @@ module Occi
|
|
654
622
|
#
|
655
623
|
#
|
656
624
|
def get_os_tpl_mixins_ary
|
657
|
-
|
658
|
-
|
659
|
-
get_os_templates.each do |os_tpl|
|
660
|
-
unless os_tpl.nil? || os_tpl.type_identifier.nil?
|
661
|
-
tid = os_tpl.type_identifier.strip
|
662
|
-
os_tpls << tid unless tid.empty?
|
663
|
-
end
|
664
|
-
end
|
625
|
+
get_mixins_ary(:os_tpl)
|
626
|
+
end
|
665
627
|
|
666
|
-
|
628
|
+
#
|
629
|
+
#
|
630
|
+
#
|
631
|
+
def get_resource_tpl_mixins_ary
|
632
|
+
get_mixins_ary(:resource_tpl)
|
667
633
|
end
|
668
634
|
|
669
635
|
#
|
670
636
|
#
|
671
637
|
#
|
672
|
-
def
|
673
|
-
|
638
|
+
def get_mixins_ary(mixin_type)
|
639
|
+
mixins = []
|
640
|
+
|
641
|
+
send("get_#{mixin_type.to_s}s".to_sym).each do |mixin|
|
642
|
+
next if mixin.nil? || mixin.type_identifier.nil?
|
674
643
|
|
675
|
-
|
676
|
-
|
677
|
-
tid = res_tpl.type_identifier.strip
|
678
|
-
res_tpls << tid unless tid.empty?
|
679
|
-
end
|
644
|
+
tid = mixin.type_identifier.strip
|
645
|
+
mixins << tid unless tid.empty?
|
680
646
|
end
|
681
647
|
|
682
|
-
|
648
|
+
mixins
|
683
649
|
end
|
684
650
|
|
685
651
|
end
|
@@ -2,7 +2,8 @@ require 'httparty'
|
|
2
2
|
|
3
3
|
require 'occi/api/client/http/net_http_fix'
|
4
4
|
require 'occi/api/client/http/httparty_fix'
|
5
|
-
require 'occi/api/client/
|
5
|
+
require 'occi/api/client/authn_utils'
|
6
|
+
require 'occi/api/client/http/authn_plugins'
|
6
7
|
|
7
8
|
module Occi
|
8
9
|
module Api
|
@@ -323,7 +324,9 @@ module Occi
|
|
323
324
|
entity_type = Occi::Core::Link if kind.related_to? Occi::Core::Link
|
324
325
|
end
|
325
326
|
|
326
|
-
Occi::
|
327
|
+
entity_type = Occi::Core::Resource unless entity_type
|
328
|
+
|
329
|
+
Occi::Log.debug "Parser call: #{response.content_type} #{path.include?('-/')} #{entity_type} #{response.headers.inspect}"
|
327
330
|
collection = Occi::Parser.parse(response.content_type, response.body, path.include?('-/'), entity_type, response.headers)
|
328
331
|
|
329
332
|
Occi::Log.debug "Parsed collection: empty? #{collection.empty?}"
|
@@ -378,7 +381,13 @@ module Occi
|
|
378
381
|
collection.resources.first.location if collection.resources.first
|
379
382
|
end
|
380
383
|
when 201
|
381
|
-
|
384
|
+
# TODO: OCCI-OS hack, look for header Location instead of uri-list
|
385
|
+
# This should be probably implemented in Occi::Parser.locations
|
386
|
+
if response.header['location']
|
387
|
+
response.header['location']
|
388
|
+
else
|
389
|
+
Occi::Parser.locations(response.header["content-type"].split(";").first, response.body, response.header).first
|
390
|
+
end
|
382
391
|
else
|
383
392
|
raise "HTTP POST failed! #{response_msg}"
|
384
393
|
end
|
@@ -481,6 +490,7 @@ module Occi
|
|
481
490
|
Occi::Log.debug e.message
|
482
491
|
|
483
492
|
if @authn_plugin.fallbacks.any?
|
493
|
+
# TODO: multiple fallbacks
|
484
494
|
@auth_options[:original_type] = @auth_options[:type]
|
485
495
|
@auth_options[:type] = @authn_plugin.fallbacks.first
|
486
496
|
|
@@ -16,7 +16,7 @@ module Occi::Api::Client
|
|
16
16
|
def setup(options = {}); end
|
17
17
|
|
18
18
|
def authenticate(options = {})
|
19
|
-
response = @env_ref.class.head @env_ref.endpoint
|
19
|
+
response = @env_ref.class.head "#{@env_ref.endpoint}-/"
|
20
20
|
raise ::Occi::Api::Client::Errors::AuthnError, "Authentication failed with code #{response.code.to_s}!" unless response.success?
|
21
21
|
end
|
22
22
|
|
@@ -5,25 +5,50 @@ module Occi::Api::Client
|
|
5
5
|
class Keystone < Base
|
6
6
|
|
7
7
|
def setup(options = {})
|
8
|
-
|
8
|
+
# get Keystone URL if possible, get unscoped token
|
9
|
+
set_keystone_base_url
|
10
|
+
set_auth_token
|
11
|
+
|
12
|
+
# use unscoped token for tenant discovery, get scoped token
|
13
|
+
tenant = get_prefered_tenant
|
14
|
+
set_auth_token(tenant)
|
15
|
+
end
|
16
|
+
|
17
|
+
def authenticate(options = {})
|
18
|
+
# OCCI-OS doesn't support HEAD method!
|
19
|
+
response = @env_ref.class.get "#{@env_ref.endpoint}-/"
|
20
|
+
raise ::Occi::Api::Client::Errors::AuthnError, "Authentication failed with code #{response.code.to_s}!" unless response.success?
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def set_keystone_base_url
|
26
|
+
response = @env_ref.class.head "#{@env_ref.endpoint}-/"
|
9
27
|
Occi::Log.debug response.inspect
|
10
28
|
|
11
29
|
return if response.success?
|
12
30
|
raise ::Occi::Api::Client::Errors::AuthnError, "Keystone AuthN failed with #{response.code.to_s}!" unless response.code == 401
|
13
31
|
|
14
32
|
unless response.headers['www-authenticate'] && response.headers['www-authenticate'].start_with?('Keystone')
|
15
|
-
raise ::Occi::Api::Client::Errors::AuthnError, "Target endpoint is probably not OpenStack!"
|
33
|
+
raise ::Occi::Api::Client::Errors::AuthnError, "Target endpoint is probably not OpenStack, fallback failed!"
|
16
34
|
end
|
17
35
|
|
18
|
-
|
36
|
+
@keystone_url = /^Keystone uri='(.+)'$/.match(response.headers['www-authenticate'])[1]
|
37
|
+
raise ::Occi::Api::Client::Errors::AuthnError, "Unable to get Keystone's URL from the response!" unless @keystone_url
|
19
38
|
|
20
|
-
|
39
|
+
@keystone_url = @keystone_url.chomp('/')
|
40
|
+
end
|
21
41
|
|
42
|
+
def set_auth_token(tenant = nil)
|
22
43
|
headers = @env_ref.class.headers.clone
|
23
44
|
headers['Content-Type'] = "application/json"
|
24
45
|
headers['Accept'] = headers['Content-Type']
|
25
46
|
|
26
|
-
response = @env_ref.class.post(
|
47
|
+
response = @env_ref.class.post(
|
48
|
+
"#{@keystone_url}/v2.0/tokens",
|
49
|
+
:body => get_keystone_req(tenant),
|
50
|
+
:headers => headers
|
51
|
+
)
|
27
52
|
Occi::Log.debug response.inspect
|
28
53
|
|
29
54
|
if response.success?
|
@@ -33,9 +58,7 @@ module Occi::Api::Client
|
|
33
58
|
end
|
34
59
|
end
|
35
60
|
|
36
|
-
|
37
|
-
|
38
|
-
def get_keystone_req(json = true)
|
61
|
+
def get_keystone_req(tenant = nil)
|
39
62
|
if @options[:original_type] == "x509"
|
40
63
|
body = { "auth" => { "voms" => true } }
|
41
64
|
elsif @options[:username] && @options[:password]
|
@@ -51,7 +74,27 @@ module Occi::Api::Client
|
|
51
74
|
raise ::Occi::Api::Client::Errors::AuthnError, "Unable to request a token from Keystone! Chosen AuthN not supported."
|
52
75
|
end
|
53
76
|
|
54
|
-
|
77
|
+
body['auth']['tenantName'] = tenant if tenant && !tenant.empty?
|
78
|
+
body.to_json
|
79
|
+
end
|
80
|
+
|
81
|
+
def get_prefered_tenant(match = nil)
|
82
|
+
headers = @env_ref.class.headers.clone
|
83
|
+
headers['Content-Type'] = "application/json"
|
84
|
+
headers['Accept'] = headers['Content-Type']
|
85
|
+
|
86
|
+
response = @env_ref.class.get(
|
87
|
+
"#{@keystone_url}/v2.0/tenants",
|
88
|
+
:headers => headers
|
89
|
+
)
|
90
|
+
Occi::Log.debug response.inspect
|
91
|
+
|
92
|
+
# TODO: impl match with regexp in case of multiple tenants?
|
93
|
+
raise ::Occi::Api::Client::Errors::AuthnError, "Keystone didn't return any tenants!" unless response['tenants'] && response['tenants'].first
|
94
|
+
tenant = response['tenants'].first['name'] if response.success?
|
95
|
+
raise ::Occi::Api::Client::Errors::AuthnError, "Unable to get a tenant from Keystone!" unless tenant
|
96
|
+
|
97
|
+
tenant
|
55
98
|
end
|
56
99
|
|
57
100
|
end
|
@@ -3,44 +3,20 @@ module HTTParty
|
|
3
3
|
|
4
4
|
private
|
5
5
|
|
6
|
-
|
7
|
-
if http.use_ssl?
|
8
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
9
|
-
|
10
|
-
# Client certificate authentication
|
11
|
-
if options[:pem]
|
12
|
-
http.cert = OpenSSL::X509::Certificate.new(options[:pem])
|
13
|
-
http.key = OpenSSL::PKey::RSA.new(options[:pem], options[:pem_password])
|
14
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
15
|
-
end
|
16
|
-
|
17
|
-
# Set chain of client certificates
|
18
|
-
if options[:ssl_extra_chain_cert]
|
19
|
-
http.extra_chain_cert = []
|
20
|
-
|
21
|
-
options[:ssl_extra_chain_cert].each do |p_ca|
|
22
|
-
http.extra_chain_cert << OpenSSL::X509::Certificate.new(p_ca)
|
23
|
-
end
|
24
|
-
end
|
6
|
+
alias_method :old_attach_ssl_certificates, :attach_ssl_certificates
|
25
7
|
|
26
|
-
|
27
|
-
|
28
|
-
http.ca_file = options[:ssl_ca_file]
|
29
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
30
|
-
end
|
8
|
+
def attach_ssl_certificates(http, options)
|
9
|
+
old_attach_ssl_certificates(http, options)
|
31
10
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
end
|
11
|
+
# Set chain of client certificates
|
12
|
+
if options[:ssl_extra_chain_cert]
|
13
|
+
http.extra_chain_cert = []
|
36
14
|
|
37
|
-
|
38
|
-
|
39
|
-
http.ssl_version = options[:ssl_version]
|
15
|
+
options[:ssl_extra_chain_cert].each do |p_ca|
|
16
|
+
http.extra_chain_cert << OpenSSL::X509::Certificate.new(p_ca)
|
40
17
|
end
|
41
18
|
end
|
42
19
|
end
|
43
|
-
|
44
20
|
end
|
45
21
|
|
46
22
|
module ClassMethods
|