cogitate 0.0.1 → 0.0.2
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 +4 -4
- data/README.md +69 -15
- data/lib/cogitate.rb +18 -2
- data/lib/cogitate/README.md +8 -0
- data/lib/cogitate/client.rb +97 -0
- data/lib/cogitate/client/agent_builder.rb +86 -0
- data/lib/cogitate/client/data_to_object_coercer.rb +38 -0
- data/lib/cogitate/client/exceptions.rb +10 -0
- data/lib/cogitate/client/identifier_builder.rb +26 -0
- data/lib/cogitate/client/request.rb +56 -0
- data/lib/cogitate/client/response_parsers.rb +19 -0
- data/lib/cogitate/client/response_parsers/agents_with_detailed_identifiers_extractor.rb +15 -0
- data/lib/cogitate/client/response_parsers/agents_without_group_membership_extractor.rb +23 -0
- data/lib/cogitate/client/response_parsers/basic_extractor.rb +14 -0
- data/lib/cogitate/client/response_parsers/email_extractor.rb +17 -0
- data/lib/cogitate/client/retrieve_agent_from_ticket.rb +43 -0
- data/lib/cogitate/client/ticket_to_token_coercer.rb +35 -0
- data/lib/cogitate/client/token_to_object_coercer.rb +38 -0
- data/lib/cogitate/configuration.rb +99 -0
- data/lib/cogitate/exceptions.rb +25 -0
- data/lib/cogitate/generators/install_generator.rb +10 -0
- data/lib/cogitate/generators/templates/cogitate_initializer.rb.erb +17 -0
- data/lib/cogitate/interfaces.rb +40 -0
- data/lib/cogitate/models.rb +6 -0
- data/lib/cogitate/models/agent.rb +143 -0
- data/lib/cogitate/models/agent/serializer.rb +75 -0
- data/lib/cogitate/models/agent/with_token.rb +29 -0
- data/lib/cogitate/models/identifier.rb +114 -0
- data/lib/cogitate/models/identifiers.rb +7 -0
- data/lib/cogitate/models/identifiers/with_attribute_hash.rb +44 -0
- data/lib/cogitate/railtie.rb +8 -0
- data/lib/cogitate/services/identifiers_decoder.rb +61 -0
- data/lib/cogitate/services/tokenizer.rb +80 -0
- metadata +105 -5
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'cogitate/interfaces'
|
2
|
+
require 'cogitate/client'
|
3
|
+
require 'cogitate/models/identifier'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
module Cogitate
|
7
|
+
module Models
|
8
|
+
# @api public
|
9
|
+
#
|
10
|
+
# An Agent is a "bucket" of attributes. It represents a single acting entity:
|
11
|
+
#
|
12
|
+
# * a Person - the human clacking away at the keyboard requesting things
|
13
|
+
# * a Service - an application that "does stuff" to data
|
14
|
+
#
|
15
|
+
# @todo Consider adding #to_token so that an Agent can fulfill the AgentWithToken interface
|
16
|
+
class Agent
|
17
|
+
# @api public
|
18
|
+
def self.build_with_identifying_information(strategy:, identifying_value:, **keywords, &block)
|
19
|
+
identifier = Cogitate::Models::Identifier.new(strategy: strategy, identifying_value: identifying_value)
|
20
|
+
new(identifier: identifier, **keywords, &block)
|
21
|
+
end
|
22
|
+
|
23
|
+
# @api public
|
24
|
+
def self.build_with_encoded_id(encoded_identifier:)
|
25
|
+
strategy, identifying_value = Cogitate::Client.extract_strategy_and_identifying_value(encoded_identifier)
|
26
|
+
build_with_identifying_information(strategy: strategy, identifying_value: identifying_value)
|
27
|
+
end
|
28
|
+
|
29
|
+
include Contracts
|
30
|
+
Contract(
|
31
|
+
Contracts::KeywordArgs[identifier: Cogitate::Interfaces::IdentifierInterface], Contracts::Any =>
|
32
|
+
Cogitate::Interfaces::AgentInterface
|
33
|
+
)
|
34
|
+
# @note I'm choosing the :identifier as the input and :primary_identifier as the attribute. I believe it is possible that during the
|
35
|
+
# process that builds the Agent, I may encounter a more applicable primary_identifier.
|
36
|
+
def initialize(identifier:, container: default_container, serializer_builder: default_serializer_builder)
|
37
|
+
self.identifiers = container.new
|
38
|
+
self.verified_identifiers = container.new
|
39
|
+
self.primary_identifier = identifier
|
40
|
+
self.serializer = serializer_builder.call(agent: self)
|
41
|
+
self.emails = container.new
|
42
|
+
yield(self) if block_given?
|
43
|
+
self
|
44
|
+
end
|
45
|
+
|
46
|
+
Contract(Cogitate::Interfaces::IdentifierInterface => Cogitate::Interfaces::IdentifierInterface)
|
47
|
+
# @api public
|
48
|
+
#
|
49
|
+
# Add an identifier to the agent.
|
50
|
+
#
|
51
|
+
# @param identifier [Cogitate::Interfaces::IdentifierInterface]
|
52
|
+
# @return [Cogitate::Interfaces::IdentifierInterface]
|
53
|
+
def add_identifier(identifier)
|
54
|
+
identifiers << identifier
|
55
|
+
identifier
|
56
|
+
end
|
57
|
+
|
58
|
+
# @api public
|
59
|
+
# @return [Enumerator, nil] nil if a block is given else an Enumerator
|
60
|
+
def with_identifiers
|
61
|
+
return enum_for(:with_identifiers) unless block_given?
|
62
|
+
identifiers.each { |identifier| yield(identifier) }
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
|
66
|
+
Contract(Cogitate::Interfaces::IdentifierInterface => Cogitate::Interfaces::IdentifierInterface)
|
67
|
+
# @api public
|
68
|
+
#
|
69
|
+
# Add a verified identifier to the agent.
|
70
|
+
#
|
71
|
+
# @param identifier [Cogitate::Interfaces::IdentifierInterface]
|
72
|
+
# @return [Cogitate::Interfaces::IdentifierInterface]
|
73
|
+
def add_verified_identifier(identifier)
|
74
|
+
verified_identifiers << identifier
|
75
|
+
identifier
|
76
|
+
end
|
77
|
+
|
78
|
+
# @api public
|
79
|
+
# @return [Enumerator, nil] nil if a block is given else an Enumerator
|
80
|
+
def with_verified_identifiers
|
81
|
+
return enum_for(:with_verified_identifiers) unless block_given?
|
82
|
+
verified_identifiers.each { |identifier| yield(identifier) }
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# @api private
|
87
|
+
# I am not yet set on this method being part of the public API.
|
88
|
+
def add_email(input)
|
89
|
+
emails << input
|
90
|
+
end
|
91
|
+
|
92
|
+
# @api public
|
93
|
+
# @return [Enumerator, nil] nil if a block is given else an Enumerator
|
94
|
+
def with_emails
|
95
|
+
return enum_for(:with_emails) unless block_given?
|
96
|
+
emails.each { |email| yield(email) }
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
|
100
|
+
# @return [Cogitate::Interfaces::IdentifierInterface] What has been assigned as the primary identifier of this agent.
|
101
|
+
attr_reader :primary_identifier
|
102
|
+
|
103
|
+
extend Forwardable
|
104
|
+
def_delegators :primary_identifier, *Cogitate::Models::Identifier.interface_method_names
|
105
|
+
def_delegators :serializer, :as_json
|
106
|
+
|
107
|
+
# Consider the scenario in which a student assigns a faculty member (via an email address) as a collaborating
|
108
|
+
# researcher. That faculty member should have permission to collaborate on the student's work. The permissions
|
109
|
+
# are stored as the Faculty's email. Then the Faculty authenticates with a NetID (a canonical identifier for the
|
110
|
+
# institution that stood up this Cogitate instance) and associates the NetID with their email address.
|
111
|
+
#
|
112
|
+
# At that point we want to allow objects associated with both the NetID and connected email to be available to
|
113
|
+
# the faculty.
|
114
|
+
#
|
115
|
+
# @api public
|
116
|
+
# @return [Array] of ids for each of the verified identifiers
|
117
|
+
def ids
|
118
|
+
[id] + with_verified_identifiers.map(&:id)
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
attr_accessor :identifiers
|
124
|
+
attr_accessor :verified_identifiers
|
125
|
+
attr_accessor :emails
|
126
|
+
|
127
|
+
Contract(Cogitate::Interfaces::IdentifierInterface => Contracts::Any)
|
128
|
+
attr_writer :primary_identifier
|
129
|
+
|
130
|
+
def default_container
|
131
|
+
require 'set' unless defined?(Set)
|
132
|
+
Set
|
133
|
+
end
|
134
|
+
|
135
|
+
attr_accessor :serializer
|
136
|
+
# @todo This is something that needs greater consideration. Is it applicable for both client and server?
|
137
|
+
def default_serializer_builder
|
138
|
+
require 'cogitate/models/agent/serializer' unless defined?(Cogitate::Models::Agent::Serializer)
|
139
|
+
Cogitate::Models::Agent::Serializer.method(:new)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'figaro'
|
2
|
+
require 'set'
|
3
|
+
require 'cogitate/models/agent'
|
4
|
+
|
5
|
+
module Cogitate
|
6
|
+
module Models
|
7
|
+
class Agent
|
8
|
+
# Responsible for the serialization of an Agent
|
9
|
+
class Serializer
|
10
|
+
# @api public
|
11
|
+
def initialize(agent:)
|
12
|
+
self.agent = agent
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
attr_accessor :agent
|
18
|
+
|
19
|
+
public
|
20
|
+
|
21
|
+
def as_json(*)
|
22
|
+
prepare_relationships_and_inclusions!
|
23
|
+
{
|
24
|
+
'type' => JSON_API_TYPE, 'id' => agent.encoded_id,
|
25
|
+
'links' => { 'self' => "#{url_for_identifier(agent.encoded_id)}" },
|
26
|
+
'attributes' => { 'strategy' => agent.strategy, 'identifying_value' => agent.identifying_value, 'emails' => emails_as_json },
|
27
|
+
'relationships' => { 'identifiers' => identities_as_json, 'verified_identifiers' => verified_identities_as_json },
|
28
|
+
'included' => included_objects_as_json
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
JSON_API_TYPE = 'agents'.freeze
|
35
|
+
def type
|
36
|
+
JSON_API_TYPE
|
37
|
+
end
|
38
|
+
|
39
|
+
def emails_as_json
|
40
|
+
agent.with_emails.each_with_object([]) do |email, mem|
|
41
|
+
mem << email
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @note This method is rather complicated but it reduces the number of times we iterate
|
46
|
+
# through each of the Enumerators.
|
47
|
+
def prepare_relationships_and_inclusions!
|
48
|
+
included_objects_as_json = Set.new
|
49
|
+
identities_as_json = Set.new
|
50
|
+
verified_identities_as_json = Set.new
|
51
|
+
|
52
|
+
agent.with_identifiers do |identifier|
|
53
|
+
identities_as_json << { 'type' => 'identifiers', 'id' => identifier.encoded_id }
|
54
|
+
included_objects_as_json << { 'type' => 'identifiers', 'id' => identifier.encoded_id, 'attributes' => identifier.as_json }
|
55
|
+
end
|
56
|
+
|
57
|
+
agent.with_verified_identifiers do |identifier|
|
58
|
+
verified_identities_as_json << { 'type' => 'identifiers', 'id' => identifier.encoded_id }
|
59
|
+
included_objects_as_json << { 'type' => 'identifiers', 'id' => identifier.encoded_id, 'attributes' => identifier.as_json }
|
60
|
+
end
|
61
|
+
|
62
|
+
@included_objects_as_json = included_objects_as_json.to_a
|
63
|
+
@identities_as_json = identities_as_json.to_a
|
64
|
+
@verified_identities_as_json = verified_identities_as_json.to_a
|
65
|
+
end
|
66
|
+
|
67
|
+
attr_reader :included_objects_as_json, :identities_as_json, :verified_identities_as_json
|
68
|
+
|
69
|
+
def url_for_identifier(encoded_identifier)
|
70
|
+
"#{Figaro.env.protocol}://#{Figaro.env.domain_name}/api/agents/#{encoded_identifier}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'cogitate/interfaces'
|
2
|
+
|
3
|
+
module Cogitate
|
4
|
+
module Models
|
5
|
+
class Agent
|
6
|
+
# A parameter style class that can be sent to the client of Cogitate.
|
7
|
+
#
|
8
|
+
# The token, if decoded and parsed will be the given agent. Likewise the agent, if encoded would be the given token.
|
9
|
+
class WithToken < SimpleDelegator
|
10
|
+
include Contracts
|
11
|
+
Contract(KeywordArgs[agent: Cogitate::Interfaces::AgentInterface, token: String] => Any)
|
12
|
+
def initialize(agent:, token:)
|
13
|
+
self.token = token
|
14
|
+
self.agent = agent
|
15
|
+
super(agent)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_accessor :agent, :token
|
21
|
+
|
22
|
+
public
|
23
|
+
|
24
|
+
alias_method :to_token, :token
|
25
|
+
public :to_token
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'contracts'
|
2
|
+
require 'cogitate/interfaces'
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
module Cogitate
|
6
|
+
module Models
|
7
|
+
# @api public
|
8
|
+
#
|
9
|
+
# A parameter object that defines how we go from decoding identifiers to extracting an identity.
|
10
|
+
class Identifier
|
11
|
+
INTERFACE_METHOD_NAMES = [:identifying_value, :<=>, :encoded_id, :id, :strategy, :name].freeze
|
12
|
+
def self.interface_method_names
|
13
|
+
INTERFACE_METHOD_NAMES
|
14
|
+
end
|
15
|
+
|
16
|
+
GROUP_STRATEGY_NAME = 'group'.freeze
|
17
|
+
include Contracts
|
18
|
+
|
19
|
+
# @api public
|
20
|
+
#
|
21
|
+
# There are implicit groups that exist and are associated with a given strategy.
|
22
|
+
# This method exists to create a consistent group name.
|
23
|
+
#
|
24
|
+
# @param strategy [String] What is the scope of this identifier (i.e. a Netid, email)
|
25
|
+
# @return [Cogitate::Models::Identifier]
|
26
|
+
def self.new_for_implicit_verified_group_by_strategy(strategy:)
|
27
|
+
new(strategy: GROUP_STRATEGY_NAME, identifying_value: %(All Verified "#{strategy.to_s.downcase}" Users))
|
28
|
+
end
|
29
|
+
|
30
|
+
Contract(Cogitate::Interfaces::IdentifierInitializationInterface => Cogitate::Interfaces::IdentifierInterface)
|
31
|
+
# @api public
|
32
|
+
#
|
33
|
+
# Initialize a value object for identification
|
34
|
+
#
|
35
|
+
# @param strategy [String] What is the scope of this identifier (i.e. a Netid, email)
|
36
|
+
# @param identifying_value [String] What is the value of this identifier (i.e. hello@test.com)
|
37
|
+
def initialize(strategy:, identifying_value:)
|
38
|
+
self.strategy = strategy
|
39
|
+
self.identifying_value = identifying_value
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
# @api public
|
44
|
+
#
|
45
|
+
# Provides context for the `identifying_value`
|
46
|
+
#
|
47
|
+
# @example 'netid', 'orcid', 'email', 'twitter' are all potential strategies
|
48
|
+
#
|
49
|
+
# @return [String] one of the contexts for identity of this object
|
50
|
+
# @see #<=>
|
51
|
+
attr_reader :strategy
|
52
|
+
|
53
|
+
# @api public
|
54
|
+
#
|
55
|
+
# For the given `strategy` what is the (hopefully) unique value that can be used for identification?
|
56
|
+
#
|
57
|
+
# @example
|
58
|
+
# Given a `strategy` of "email", examples of identifying values are:
|
59
|
+
# * hello@world.com
|
60
|
+
# * test@test.com
|
61
|
+
#
|
62
|
+
# It is also possible that someone might say 'taco' is an identifying value for the email strategy.
|
63
|
+
# And that is fine.
|
64
|
+
#
|
65
|
+
# @return [String] one of the contexts for identity of this object
|
66
|
+
# @see #<=>
|
67
|
+
attr_reader :identifying_value
|
68
|
+
|
69
|
+
alias_method :name, :identifying_value
|
70
|
+
|
71
|
+
# The JSON representation of this object
|
72
|
+
#
|
73
|
+
# @return [Hash]
|
74
|
+
def as_json(*)
|
75
|
+
{ 'identifying_value' => identifying_value, 'strategy' => strategy }
|
76
|
+
end
|
77
|
+
|
78
|
+
include Comparable
|
79
|
+
|
80
|
+
Contract(Cogitate::Interfaces::IdentifierInterface => Contracts::Num)
|
81
|
+
# @api public
|
82
|
+
#
|
83
|
+
# Provide a means of sorting.
|
84
|
+
#
|
85
|
+
# @return [Integer] -1, 0, 1 as per `Comparable#<=>` interface
|
86
|
+
def <=>(other)
|
87
|
+
strategy_sort = strategy <=> other.strategy
|
88
|
+
return strategy_sort if strategy_sort != 0
|
89
|
+
identifying_value <=> other.identifying_value
|
90
|
+
end
|
91
|
+
|
92
|
+
# @api public
|
93
|
+
#
|
94
|
+
# A URL safe encoding of this object's `strategy` and `identifying_value`
|
95
|
+
#
|
96
|
+
# @return [String] a URL safe encoding of the object's identifying attributes
|
97
|
+
def encoded_id
|
98
|
+
Base64.urlsafe_encode64("#{strategy}\t#{identifying_value}")
|
99
|
+
end
|
100
|
+
|
101
|
+
alias_method :id, :encoded_id
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
# @api private
|
106
|
+
attr_writer :identifying_value
|
107
|
+
|
108
|
+
# @api private
|
109
|
+
def strategy=(value)
|
110
|
+
@strategy = value.to_s.downcase
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'cogitate/interfaces'
|
2
|
+
require 'cogitate/models/identifier'
|
3
|
+
module Cogitate
|
4
|
+
module Models
|
5
|
+
module Identifiers
|
6
|
+
# Responsible for exposing
|
7
|
+
class WithAttributeHash
|
8
|
+
include Contracts
|
9
|
+
Contract(
|
10
|
+
Contracts::KeywordArgs[identifier: ::Cogitate::Interfaces::IdentifierInterface, attributes: Contracts::HashOf[String, Any]] =>
|
11
|
+
Contracts::Any
|
12
|
+
)
|
13
|
+
def initialize(identifier:, attributes: {})
|
14
|
+
self.identifier = identifier
|
15
|
+
self.attributes = attributes
|
16
|
+
self.attributes.freeze
|
17
|
+
end
|
18
|
+
|
19
|
+
def as_json(*)
|
20
|
+
{ 'strategy' => strategy, 'identifying_value' => identifying_value }.merge(attributes)
|
21
|
+
end
|
22
|
+
|
23
|
+
extend Forwardable
|
24
|
+
include Comparable
|
25
|
+
def_delegators :identifier, *Cogitate::Models::Identifier.interface_method_names
|
26
|
+
|
27
|
+
attr_reader :attributes
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
attr_accessor :identifier
|
32
|
+
attr_writer :attributes
|
33
|
+
|
34
|
+
def method_missing(method_name, *args, &block)
|
35
|
+
attributes.fetch(method_name.to_s) { super }
|
36
|
+
end
|
37
|
+
|
38
|
+
def respond_to_missing?(method_name, *args)
|
39
|
+
attributes.key?(method_name.to_s) || super
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'contracts'
|
3
|
+
require 'cogitate/interfaces'
|
4
|
+
require 'cogitate/exceptions'
|
5
|
+
require 'cogitate/client'
|
6
|
+
|
7
|
+
module Cogitate
|
8
|
+
module Services
|
9
|
+
# A service module for extracting identifiers from an encoded payload.
|
10
|
+
#
|
11
|
+
# @example
|
12
|
+
#
|
13
|
+
# encoded_string = Base64.urlsafe_encode64("netid\tmynetid")
|
14
|
+
#
|
15
|
+
#
|
16
|
+
# @see Cogitate::Services::IdentifieresDecoder.call
|
17
|
+
module IdentifiersDecoder
|
18
|
+
# Responsible for decoding a string into a collection of identifier types and identifier ids
|
19
|
+
#
|
20
|
+
# @param encoded_string [#to_s] The thing we are going to decode
|
21
|
+
# @return [Array[Hash]]
|
22
|
+
# @raise InvalidIdentifierFormat
|
23
|
+
# @raise InvalidIdentifierEncoding
|
24
|
+
#
|
25
|
+
# @api public
|
26
|
+
# @note I have chosen to not use a keyword, as I don't want to imply the "form" of object is that is being passed in.
|
27
|
+
# @todo Determine if we should return an Array of hashes or if it should be a more proper class?
|
28
|
+
extend Contracts
|
29
|
+
Contract(
|
30
|
+
String, { Contracts::KeywordArgs[identifier_builder: Contracts::Func[Cogitate::Interfaces::IdentifierBuilderInterface]] =>
|
31
|
+
Contracts::ArrayOf[Cogitate::Interfaces::IdentifierInterface] }
|
32
|
+
)
|
33
|
+
def self.call(encoded_string, identifier_builder: default_identifier_builder)
|
34
|
+
decoded_string = decode(encoded_string)
|
35
|
+
|
36
|
+
decoded_string.split("\n").each_with_object([]) do |strategy_value, object|
|
37
|
+
strategy, value = strategy_value.split("\t")
|
38
|
+
if strategy.to_s.size == 0 || value.to_s.size == 0
|
39
|
+
fail Cogitate::InvalidIdentifierFormat, decoded_string: decoded_string
|
40
|
+
end
|
41
|
+
object << identifier_builder.call(strategy: strategy, identifying_value: value)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# @api private
|
46
|
+
def self.decode(encoded_string)
|
47
|
+
Base64.urlsafe_decode64(encoded_string.to_s)
|
48
|
+
rescue ArgumentError
|
49
|
+
raise Cogitate::InvalidIdentifierEncoding, encoded_string: encoded_string
|
50
|
+
end
|
51
|
+
private_class_method :decode
|
52
|
+
|
53
|
+
# @api private
|
54
|
+
def self.default_identifier_builder
|
55
|
+
require "cogitate/models/identifier" unless defined?(Cogitate::Models::Identifier)
|
56
|
+
Cogitate::Models::Identifier.method(:new)
|
57
|
+
end
|
58
|
+
private_class_method :default_identifier_builder
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|