qoobaa-opensocial 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.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +202 -0
- data/NOTICE +60 -0
- data/README.rdoc +99 -0
- data/Rakefile +58 -0
- data/VERSION +1 -0
- data/lib/opensocial.rb +31 -0
- data/lib/opensocial/activity.rb +113 -0
- data/lib/opensocial/appdata.rb +114 -0
- data/lib/opensocial/auth/action_controller_request.rb +88 -0
- data/lib/opensocial/auth/base.rb +109 -0
- data/lib/opensocial/base.rb +50 -0
- data/lib/opensocial/connection.rb +128 -0
- data/lib/opensocial/group.rb +80 -0
- data/lib/opensocial/person.rb +197 -0
- data/lib/opensocial/request.rb +263 -0
- data/lib/opensocial/string/merb_string.rb +32 -0
- data/lib/opensocial/string/os_string.rb +23 -0
- data/qoobaa-opensocial.gemspec +99 -0
- data/test/fixtures/activities.json +28 -0
- data/test/fixtures/activity.json +13 -0
- data/test/fixtures/appdata.json +6 -0
- data/test/fixtures/appdatum.json +5 -0
- data/test/fixtures/group.json +7 -0
- data/test/fixtures/groups.json +16 -0
- data/test/fixtures/people.json +20 -0
- data/test/fixtures/person.json +9 -0
- data/test/fixtures/person_appdata_rpc.json +1 -0
- data/test/fixtures/person_rpc.json +1 -0
- data/test/helper.rb +41 -0
- data/test/test_activity.rb +105 -0
- data/test/test_appdata.rb +57 -0
- data/test/test_connection.rb +52 -0
- data/test/test_group.rb +70 -0
- data/test/test_online.rb +67 -0
- data/test/test_person.rb +140 -0
- data/test/test_request.rb +49 -0
- data/test/test_rpcrequest.rb +93 -0
- metadata +157 -0
@@ -0,0 +1,263 @@
|
|
1
|
+
# Copyright (c) 2008 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
module OpenSocial #:nodoc:
|
16
|
+
|
17
|
+
# Provides a wrapper for a single request to an OpenSocial endpoint, either
|
18
|
+
# as a standalone request, or as a fragment of an RPC request.
|
19
|
+
#
|
20
|
+
# The Request class wraps an HTTP request to an OpenSocial endpoint for
|
21
|
+
# social data. A Request may be used, directly, to access social resources
|
22
|
+
# that may not be currently wrapped by Request's child classes. Used in this
|
23
|
+
# was it gives near-raw access to social data. The Request class supplies
|
24
|
+
# OAuth signing when the supplied connection contains appropriate
|
25
|
+
# credentials.
|
26
|
+
#
|
27
|
+
|
28
|
+
|
29
|
+
class Request
|
30
|
+
GET = ".get"
|
31
|
+
|
32
|
+
# Defines the connection that will be used in the request.
|
33
|
+
attr_accessor :connection
|
34
|
+
|
35
|
+
# Defines the guid for the request.
|
36
|
+
attr_accessor :guid
|
37
|
+
|
38
|
+
# Defines the selector for the request.
|
39
|
+
attr_accessor :selector
|
40
|
+
|
41
|
+
# Defines the pid for the request.
|
42
|
+
attr_accessor :pid
|
43
|
+
|
44
|
+
# Defines the key used to lookup the request result in an RPC request.
|
45
|
+
attr_accessor :key
|
46
|
+
|
47
|
+
# Initializes a request using the optionally supplied connection, guid,
|
48
|
+
# selector, and pid.
|
49
|
+
def initialize(connection = nil, guid = nil, selector = nil, pid = nil)
|
50
|
+
@connection = connection
|
51
|
+
@guid = guid
|
52
|
+
@selector = selector
|
53
|
+
@pid = pid
|
54
|
+
end
|
55
|
+
|
56
|
+
# Generates a request given the service, guid, selector, and pid, to the
|
57
|
+
# OpenSocial endpoint by constructing the service URI and dispatching the
|
58
|
+
# request. When data is returned, it is parsed as JSON after being
|
59
|
+
# optionally unescaped.
|
60
|
+
def send_request(service, guid, selector = nil, pid = nil,
|
61
|
+
unescape = false, extra_fields = {})
|
62
|
+
if !@connection
|
63
|
+
raise RequestException.new("Request requires a valid connection.")
|
64
|
+
end
|
65
|
+
|
66
|
+
uri = @connection.service_uri(@connection.container[:rest] + service,
|
67
|
+
guid, selector, pid, extra_fields)
|
68
|
+
data = dispatch(uri)
|
69
|
+
|
70
|
+
if unescape
|
71
|
+
JSON.parse(data.os_unescape)
|
72
|
+
else
|
73
|
+
JSON.parse(data)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Dispatches a request to a given URI with optional POST data. If a
|
80
|
+
# request's connection has specified HMAC-SHA1 authentication, OAuth
|
81
|
+
# parameters and signature are appended to the request.
|
82
|
+
def dispatch(uri, post_data = nil)
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
84
|
+
|
85
|
+
if post_data
|
86
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
87
|
+
|
88
|
+
# FIXME: post_data is a serialized JSON string - set_form_data
|
89
|
+
# requires a hash, using body= method instead (doesn't work as
|
90
|
+
# well)
|
91
|
+
|
92
|
+
# req.set_form_data(post_data)
|
93
|
+
req.body = post_data
|
94
|
+
else
|
95
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
96
|
+
end
|
97
|
+
|
98
|
+
@connection.sign!(http, req)
|
99
|
+
|
100
|
+
if post_data
|
101
|
+
resp = http.post(req.path, post_data)
|
102
|
+
check_for_json_error!(resp)
|
103
|
+
else
|
104
|
+
resp = http.get(req.path)
|
105
|
+
check_for_http_error!(resp, uri) # TODO uri for debugging only
|
106
|
+
end
|
107
|
+
|
108
|
+
return resp.body
|
109
|
+
end
|
110
|
+
|
111
|
+
# Checks the response object's status code. If the response is is
|
112
|
+
# unauthorized, an exception is raised.
|
113
|
+
def check_for_http_error!(resp, req_uri=nil) # TODO uri for debugging only
|
114
|
+
if !resp.kind_of?(Net::HTTPSuccess)
|
115
|
+
if resp.is_a?(Net::HTTPUnauthorized)
|
116
|
+
if Object.const_defined?(:HoptoadNotifier)
|
117
|
+
HoptoadNotifier.notify(:error_class => "Myspace API Auth error",
|
118
|
+
:error_message => "Myspace API Auth error",
|
119
|
+
:request => {:params => {"body" => resp.body,
|
120
|
+
"uri" => req_uri.to_s}})
|
121
|
+
end
|
122
|
+
raise AuthException.new("The request lacked proper authentication " +
|
123
|
+
"credentials to retrieve data.")
|
124
|
+
else
|
125
|
+
resp.value
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Checks the JSON response for a status code. If a code is present an
|
131
|
+
# exception is raised.
|
132
|
+
def check_for_json_error!(resp)
|
133
|
+
json = JSON.parse(resp.body)
|
134
|
+
if json.is_a?(Hash) && json.has_key?("code") && json.has_key?("message")
|
135
|
+
rc = json["code"]
|
136
|
+
message = json["message"]
|
137
|
+
case rc
|
138
|
+
when 401
|
139
|
+
raise AuthException.new("The request lacked proper authentication " +
|
140
|
+
"credentials to retrieve data.")
|
141
|
+
else
|
142
|
+
raise RequestException.new("The request returned an unsupported " +
|
143
|
+
"status code: #{rc} #{message}.")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Provides a wrapper for a single RPC request to an OpenSocial endpoint,
|
150
|
+
# composed of one or more individual requests.
|
151
|
+
#
|
152
|
+
# The RpcRequest class wraps an HTTP request to an OpenSocial endpoint for
|
153
|
+
# social data. An RpcRequest is intended to be used as a container for one
|
154
|
+
# or more Requests (or Fetch*Requests), but may also be used with a manually
|
155
|
+
# constructed post body. The RpcRequest class uses OAuth signing inherited
|
156
|
+
# from the Request class, when appropriate OAuth credentials are supplied.
|
157
|
+
#
|
158
|
+
|
159
|
+
|
160
|
+
class RpcRequest < Request
|
161
|
+
|
162
|
+
# Defines the requests sent in the single RpcRequest. The requests are
|
163
|
+
# stored a key/value pairs.
|
164
|
+
attr_accessor :requests
|
165
|
+
|
166
|
+
# Initializes an RpcRequest with the supplied connection and an optional
|
167
|
+
# hash of requests.
|
168
|
+
def initialize(connection, requests = {})
|
169
|
+
@connection = connection
|
170
|
+
|
171
|
+
@requests = requests
|
172
|
+
end
|
173
|
+
|
174
|
+
# Adds one or more requests to the RpcRequest. Expects a hash of key/value
|
175
|
+
# pairs (key used to refernece the data when it returns => the Request).
|
176
|
+
def add(requests = {})
|
177
|
+
@requests.merge!(requests)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Sends an RpcRequest to the OpenSocial endpoint by constructing JSON for
|
181
|
+
# the POST body and delegating the request to send_request. If an
|
182
|
+
# RpcRequest is sent with an empty list of requests, an exception is
|
183
|
+
# thrown. The response JSON is optionally unescaped (defaulting to true).
|
184
|
+
def send(unescape = true)
|
185
|
+
if @requests.length == 0
|
186
|
+
raise RequestException.new("RPC request requires a non-empty hash " +
|
187
|
+
"of requests in order to be sent.")
|
188
|
+
end
|
189
|
+
|
190
|
+
json = send_request(request_json, unescape)
|
191
|
+
end
|
192
|
+
|
193
|
+
# Sends an RpcRequest to the OpenSocial endpoint by constructing the
|
194
|
+
# service URI and dispatching the request. This method is public so that
|
195
|
+
# an arbitrary POST body can be constructed and sent. The response JSON is
|
196
|
+
# optionally unescaped.
|
197
|
+
def send_request(post_data, unescape)
|
198
|
+
uri = @connection.service_uri(@connection.container[:rpc], nil, nil, nil)
|
199
|
+
data = dispatch(uri, post_data)
|
200
|
+
|
201
|
+
parse_response(data, unescape)
|
202
|
+
end
|
203
|
+
|
204
|
+
private
|
205
|
+
|
206
|
+
# Parses the response JSON. First, the JSON is unescaped, when specified,
|
207
|
+
# then for each element specified in @requests, the appropriate response
|
208
|
+
# is selected from the larger JSON response. This element is then delegated
|
209
|
+
# to the appropriate class to be turned into a native object (Person,
|
210
|
+
# Activity, etc.)
|
211
|
+
def parse_response(response, unescape)
|
212
|
+
if unescape
|
213
|
+
parsed = JSON.parse(response.os_unescape)
|
214
|
+
else
|
215
|
+
parsed = JSON.parse(response)
|
216
|
+
end
|
217
|
+
keyed_by_id = key_by_id(parsed)
|
218
|
+
|
219
|
+
native_objects = {}
|
220
|
+
@requests.each_pair do |key, request|
|
221
|
+
native_object = request.parse_rpc_response(keyed_by_id[key.to_s])
|
222
|
+
native_objects.merge!({key => native_object})
|
223
|
+
end
|
224
|
+
|
225
|
+
return native_objects
|
226
|
+
end
|
227
|
+
|
228
|
+
# Constructs a hash of the elements in data referencing each element
|
229
|
+
# by its 'id' attribute.
|
230
|
+
def key_by_id(data)
|
231
|
+
keyed_by_id = {}
|
232
|
+
for entry in data
|
233
|
+
keyed_by_id.merge!({entry["id"] => entry})
|
234
|
+
end
|
235
|
+
|
236
|
+
return keyed_by_id
|
237
|
+
end
|
238
|
+
|
239
|
+
# Modifies each request in an outgoing RpcRequest so that its key is set
|
240
|
+
# to the value specified when added to the RpcRequest.
|
241
|
+
def request_json
|
242
|
+
keyed_requests = []
|
243
|
+
@requests.each_pair do |key, request|
|
244
|
+
request.key = key
|
245
|
+
keyed_requests << request
|
246
|
+
end
|
247
|
+
|
248
|
+
return keyed_requests.to_json
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# An exception thrown when a request cannot return data.
|
253
|
+
#
|
254
|
+
|
255
|
+
|
256
|
+
class RequestException < RuntimeError; end
|
257
|
+
|
258
|
+
# An exception thrown when a request returns a 401 unauthorized status.
|
259
|
+
#
|
260
|
+
|
261
|
+
|
262
|
+
class AuthException < RuntimeError; end
|
263
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Copyright (c) 2008 Engine Yard
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
# a copy of this software and associated documentation files (the
|
5
|
+
# "Software"), to deal in the Software without restriction, including
|
6
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
# the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be
|
12
|
+
# included in all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
class String #:nodoc:
|
23
|
+
def snake_case
|
24
|
+
gsub(/\B[A-Z]/, '_\&').downcase
|
25
|
+
end
|
26
|
+
|
27
|
+
def camel_case
|
28
|
+
words = split('_')
|
29
|
+
camel = words[1..-1].map { |e| e.capitalize }.join
|
30
|
+
words[0] + camel
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# Copyright (c) 2008 Google Inc.
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
class String #:nodoc:
|
16
|
+
# Removes backslash escaping.
|
17
|
+
def os_unescape
|
18
|
+
unescaped = self.gsub(/\\\"/, '"')
|
19
|
+
unescaped = unescaped.gsub('"{', "{")
|
20
|
+
unescaped = unescaped.gsub('}"', "}")
|
21
|
+
unescaped = unescaped.gsub(/\"\"/, "\"")
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{qoobaa-opensocial}
|
8
|
+
s.version = "0.1.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Jakub Ku\305\272ma", "Piotr Sarnacki"]
|
12
|
+
s.date = %q{2010-03-24}
|
13
|
+
s.description = %q{OpenSocial Google Gem}
|
14
|
+
s.email = %q{qoobaa@gmail.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".gitignore",
|
22
|
+
"LICENSE",
|
23
|
+
"NOTICE",
|
24
|
+
"README.rdoc",
|
25
|
+
"Rakefile",
|
26
|
+
"VERSION",
|
27
|
+
"lib/opensocial.rb",
|
28
|
+
"lib/opensocial/activity.rb",
|
29
|
+
"lib/opensocial/appdata.rb",
|
30
|
+
"lib/opensocial/auth/action_controller_request.rb",
|
31
|
+
"lib/opensocial/auth/base.rb",
|
32
|
+
"lib/opensocial/base.rb",
|
33
|
+
"lib/opensocial/connection.rb",
|
34
|
+
"lib/opensocial/group.rb",
|
35
|
+
"lib/opensocial/person.rb",
|
36
|
+
"lib/opensocial/request.rb",
|
37
|
+
"lib/opensocial/string/merb_string.rb",
|
38
|
+
"lib/opensocial/string/os_string.rb",
|
39
|
+
"qoobaa-opensocial.gemspec",
|
40
|
+
"test/fixtures/activities.json",
|
41
|
+
"test/fixtures/activity.json",
|
42
|
+
"test/fixtures/appdata.json",
|
43
|
+
"test/fixtures/appdatum.json",
|
44
|
+
"test/fixtures/group.json",
|
45
|
+
"test/fixtures/groups.json",
|
46
|
+
"test/fixtures/people.json",
|
47
|
+
"test/fixtures/person.json",
|
48
|
+
"test/fixtures/person_appdata_rpc.json",
|
49
|
+
"test/fixtures/person_rpc.json",
|
50
|
+
"test/helper.rb",
|
51
|
+
"test/test_activity.rb",
|
52
|
+
"test/test_appdata.rb",
|
53
|
+
"test/test_connection.rb",
|
54
|
+
"test/test_group.rb",
|
55
|
+
"test/test_online.rb",
|
56
|
+
"test/test_person.rb",
|
57
|
+
"test/test_request.rb",
|
58
|
+
"test/test_rpcrequest.rb"
|
59
|
+
]
|
60
|
+
s.homepage = %q{http://github.com/qoobaa/opensocial}
|
61
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
62
|
+
s.require_paths = ["lib"]
|
63
|
+
s.rubygems_version = %q{1.3.6}
|
64
|
+
s.summary = %q{OpenSocial Google Gem}
|
65
|
+
s.test_files = [
|
66
|
+
"test/test_request.rb",
|
67
|
+
"test/test_rpcrequest.rb",
|
68
|
+
"test/test_connection.rb",
|
69
|
+
"test/test_appdata.rb",
|
70
|
+
"test/test_person.rb",
|
71
|
+
"test/test_activity.rb",
|
72
|
+
"test/helper.rb",
|
73
|
+
"test/test_group.rb",
|
74
|
+
"test/test_online.rb"
|
75
|
+
]
|
76
|
+
|
77
|
+
if s.respond_to? :specification_version then
|
78
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
79
|
+
s.specification_version = 3
|
80
|
+
|
81
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
82
|
+
s.add_runtime_dependency(%q<oauth>, [">= 0"])
|
83
|
+
s.add_development_dependency(%q<test-unit>, [">= 2"])
|
84
|
+
s.add_development_dependency(%q<json_pure>, [">= 0"])
|
85
|
+
s.add_development_dependency(%q<mocha>, [">= 0"])
|
86
|
+
else
|
87
|
+
s.add_dependency(%q<oauth>, [">= 0"])
|
88
|
+
s.add_dependency(%q<test-unit>, [">= 2"])
|
89
|
+
s.add_dependency(%q<json_pure>, [">= 0"])
|
90
|
+
s.add_dependency(%q<mocha>, [">= 0"])
|
91
|
+
end
|
92
|
+
else
|
93
|
+
s.add_dependency(%q<oauth>, [">= 0"])
|
94
|
+
s.add_dependency(%q<test-unit>, [">= 2"])
|
95
|
+
s.add_dependency(%q<json_pure>, [">= 0"])
|
96
|
+
s.add_dependency(%q<mocha>, [">= 0"])
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
@@ -0,0 +1,28 @@
|
|
1
|
+
{
|
2
|
+
"totalResults" : 2,
|
3
|
+
"startIndex" : 0,
|
4
|
+
"entry" : [
|
5
|
+
{
|
6
|
+
"id" : "http://example.org/activities/example.org:87ead8dead6beef/self/af3778",
|
7
|
+
"title" : { "type" : "html",
|
8
|
+
"value" : "<a href=\"foo\">some activity</a>"
|
9
|
+
},
|
10
|
+
"updated" : "2008-02-20T23:35:37.266Z",
|
11
|
+
"body" : "Some details for some activity",
|
12
|
+
"bodyId" : "383777272",
|
13
|
+
"url" : "http://api.example.org/activity/feeds/.../af3778",
|
14
|
+
"userId" : "example.org:34KJDCSKJN2HHF0DW20394"
|
15
|
+
},
|
16
|
+
{
|
17
|
+
"id" : "http://example.org/activities/example.org:87ead8dead6beef/self/af3779",
|
18
|
+
"title" : { "type" : "html",
|
19
|
+
"value" : "<a href=\"foo\">some activity</a>"
|
20
|
+
},
|
21
|
+
"updated" : "2008-02-20T23:35:38.266Z",
|
22
|
+
"body" : "Some details for some second activity",
|
23
|
+
"bodyId" : "383777273",
|
24
|
+
"url" : "http://api.example.org/activity/feeds/.../af3779",
|
25
|
+
"userId" : "example.org:34KJDCSKJN2HHF0DW20394"
|
26
|
+
}
|
27
|
+
]
|
28
|
+
}
|