lelylan-rb 0.0.1
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/.gitignore +26 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +7 -0
- data/Guardfile +5 -0
- data/LICENSE.md +1 -0
- data/README.md +218 -0
- data/Rakefile +21 -0
- data/lelylan_rb.gemspec +36 -0
- data/lib/faraday/response/raise_http_error.rb +35 -0
- data/lib/lelylan.rb +26 -0
- data/lib/lelylan/authentication.rb +11 -0
- data/lib/lelylan/client.rb +47 -0
- data/lib/lelylan/client/categories.rb +112 -0
- data/lib/lelylan/client/consumptions.rb +93 -0
- data/lib/lelylan/client/devices.rb +211 -0
- data/lib/lelylan/client/functions.rb +118 -0
- data/lib/lelylan/client/histories.rb +42 -0
- data/lib/lelylan/client/locations.rb +92 -0
- data/lib/lelylan/client/properties.rb +115 -0
- data/lib/lelylan/client/statuses.rb +110 -0
- data/lib/lelylan/client/types.rb +109 -0
- data/lib/lelylan/configuration.rb +65 -0
- data/lib/lelylan/connection.rb +33 -0
- data/lib/lelylan/error.rb +34 -0
- data/lib/lelylan/request.rb +70 -0
- data/lib/lelylan/version.rb +15 -0
- data/spec/faraday/response_spec.rb +37 -0
- data/spec/fixtures/categories.json +7 -0
- data/spec/fixtures/category.json +7 -0
- data/spec/fixtures/consumption.json +11 -0
- data/spec/fixtures/consumptions.json +11 -0
- data/spec/fixtures/device.json +25 -0
- data/spec/fixtures/devices.json +25 -0
- data/spec/fixtures/function.json +15 -0
- data/spec/fixtures/functions.json +15 -0
- data/spec/fixtures/histories.json +16 -0
- data/spec/fixtures/history.json +16 -0
- data/spec/fixtures/location.json +55 -0
- data/spec/fixtures/locations.json +18 -0
- data/spec/fixtures/oauth2/refresh.json +6 -0
- data/spec/fixtures/oauth2/token.json +6 -0
- data/spec/fixtures/pending.json +12 -0
- data/spec/fixtures/properties.json +9 -0
- data/spec/fixtures/property.json +9 -0
- data/spec/fixtures/status.json +24 -0
- data/spec/fixtures/statuses.json +24 -0
- data/spec/fixtures/type.json +94 -0
- data/spec/fixtures/types.json +88 -0
- data/spec/helper.rb +71 -0
- data/spec/lelylan/client/categories_spec.rb +178 -0
- data/spec/lelylan/client/consumptions_spec.rb +150 -0
- data/spec/lelylan/client/devices_spec.rb +342 -0
- data/spec/lelylan/client/functions_spec.rb +184 -0
- data/spec/lelylan/client/histories_spec.rb +64 -0
- data/spec/lelylan/client/locations_spec.rb +155 -0
- data/spec/lelylan/client/properties_spec.rb +184 -0
- data/spec/lelylan/client/statuses_spec.rb +184 -0
- data/spec/lelylan/client/types_spec.rb +184 -0
- data/spec/lelylan/client_spec.rb +32 -0
- data/spec/lelylan/oauth2_spec.rb +54 -0
- data/spec/lelylan_spec.rb +32 -0
- metadata +351 -0
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'lelylan/version'
|
3
|
+
|
4
|
+
module Lelylan
|
5
|
+
module Configuration
|
6
|
+
VALID_OPTIONS_KEYS = [
|
7
|
+
:adapter,
|
8
|
+
:api_version,
|
9
|
+
:api_endpoint,
|
10
|
+
:web_endpoint,
|
11
|
+
:endpoint,
|
12
|
+
:user,
|
13
|
+
:password,
|
14
|
+
:proxy,
|
15
|
+
:token,
|
16
|
+
:user_agent,
|
17
|
+
:auto_traversal,
|
18
|
+
:per_page].freeze
|
19
|
+
|
20
|
+
DEFAULT_ADAPTER = Faraday.default_adapter
|
21
|
+
DEFAULT_API_VERSION = 0
|
22
|
+
DEFAULT_API_ENDPOINT = 'http://api.lelylan.com/'
|
23
|
+
DEFAULT_WEB_ENDPOINT = 'http://lelylan.com/'
|
24
|
+
DEFAULT_USER_AGENT = "Lelylan Ruby Gem #{Lelylan::Version}".freeze
|
25
|
+
DEFAULT_AUTO_TRAVERSAL = false
|
26
|
+
|
27
|
+
attr_accessor(*VALID_OPTIONS_KEYS)
|
28
|
+
|
29
|
+
def self.extended(base)
|
30
|
+
base.reset
|
31
|
+
end
|
32
|
+
|
33
|
+
def configure
|
34
|
+
yield self
|
35
|
+
end
|
36
|
+
|
37
|
+
def options
|
38
|
+
VALID_OPTIONS_KEYS.inject({}){|o,k| o.merge!(k => send(k)) }
|
39
|
+
end
|
40
|
+
|
41
|
+
def api_endpoint=(value)
|
42
|
+
@api_endpoint = File.join(value, "")
|
43
|
+
end
|
44
|
+
|
45
|
+
def web_endpoint=(value)
|
46
|
+
@web_endpoint = File.join(value, "")
|
47
|
+
end
|
48
|
+
|
49
|
+
alias :endpoint= :api_endpoint=
|
50
|
+
alias :endpoint :api_endpoint
|
51
|
+
|
52
|
+
def reset
|
53
|
+
self.adapter = DEFAULT_ADAPTER
|
54
|
+
self.api_version = DEFAULT_API_VERSION
|
55
|
+
self.api_endpoint = DEFAULT_API_ENDPOINT
|
56
|
+
self.web_endpoint = DEFAULT_WEB_ENDPOINT
|
57
|
+
self.user = nil
|
58
|
+
self.password = nil
|
59
|
+
self.proxy = nil
|
60
|
+
self.token = nil
|
61
|
+
self.user_agent = DEFAULT_USER_AGENT
|
62
|
+
self.auto_traversal = DEFAULT_AUTO_TRAVERSAL
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'faraday_middleware'
|
2
|
+
require 'faraday/response/raise_http_error'
|
3
|
+
|
4
|
+
module Lelylan
|
5
|
+
module Connection
|
6
|
+
private
|
7
|
+
|
8
|
+
def connection(authenticate=true, raw=false, version=0, force_urlencoded=false)
|
9
|
+
|
10
|
+
options = {
|
11
|
+
:headers => {'Accept' => 'application/json', 'User-Agent' => user_agent, 'Content-Type' => 'application/json'},
|
12
|
+
:proxy => proxy,
|
13
|
+
:ssl => { :verify => false },
|
14
|
+
:url => Lelylan.api_endpoint
|
15
|
+
}
|
16
|
+
|
17
|
+
if authenticated?
|
18
|
+
self.token = token.refresh! if token.expired?
|
19
|
+
options[:headers].merge!('Authorization' => "Bearer #{token.token}")
|
20
|
+
end
|
21
|
+
|
22
|
+
connection = Faraday.new(options) do |builder|
|
23
|
+
builder.request :json
|
24
|
+
builder.use Faraday::Response::RaiseHttpError
|
25
|
+
builder.use FaradayMiddleware::Mashify
|
26
|
+
builder.use FaradayMiddleware::ParseJson
|
27
|
+
builder.adapter(adapter)
|
28
|
+
end
|
29
|
+
|
30
|
+
connection
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Lelylan
|
2
|
+
# Internal: Custom error class for rescuing from all Lelylan errors
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
# Internal: Raised when Lelylan returns a 400 HTTP status code
|
6
|
+
class BadRequest < Error; end
|
7
|
+
|
8
|
+
# Internal: Raised when Lelylan returns a 401 HTTP status code
|
9
|
+
class Unauthorized < Error; end
|
10
|
+
|
11
|
+
# Internal: Raised when Lelylan returns a 403 HTTP status code
|
12
|
+
class Forbidden < Error; end
|
13
|
+
|
14
|
+
# Internal: Raised when Lelylan returns a 404 HTTP status code
|
15
|
+
class NotFound < Error; end
|
16
|
+
|
17
|
+
# Internal: Raised when Lelylan returns a 406 HTTP status code
|
18
|
+
class NotAcceptable < Error; end
|
19
|
+
|
20
|
+
# Internal: Raised when Lelylan returns a 422 HTTP status code
|
21
|
+
class UnprocessableEntity < Error; end
|
22
|
+
|
23
|
+
# Internal: Raised when Lelylan returns a 500 HTTP status code
|
24
|
+
class InternalServerError < Error; end
|
25
|
+
|
26
|
+
# Internal: Raised when Lelylan returns a 501 HTTP status code
|
27
|
+
class NotImplemented < Error; end
|
28
|
+
|
29
|
+
# Internal: Raised when Lelylan returns a 502 HTTP status code
|
30
|
+
class BadGateway < Error; end
|
31
|
+
|
32
|
+
# Internal: Raised when Lelylan returns a 503 HTTP status code
|
33
|
+
class ServiceUnavailable < Error; end
|
34
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'multi_json'
|
2
|
+
|
3
|
+
module Lelylan
|
4
|
+
module Request
|
5
|
+
|
6
|
+
# Internal: Perform an HTTP DELETE request.
|
7
|
+
def delete(path, options={}, authenticate=true, raw=false, version=api_version, force_urlencoded=false)
|
8
|
+
request(:delete, path, options, authenticate, raw, version, force_urlencoded)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Internal: Perform an HTTP GET request.
|
12
|
+
def get(path, options={}, authenticate=true, raw=false, version=api_version, force_urlencoded=false)
|
13
|
+
request(:get, path, options, authenticate, raw, version, force_urlencoded)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Internal: Perform an HTTP PATCH request.
|
17
|
+
def patch(path, options={}, authenticate=true, raw=false, version=api_version, force_urlencoded=false)
|
18
|
+
request(:patch, path, options, authenticate, raw, version, force_urlencoded)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Internal: Perform an HTTP POST request.
|
22
|
+
def post(path, options={}, authenticate=true, raw=false, version=api_version, force_urlencoded=false)
|
23
|
+
request(:post, path, options, authenticate, raw, version, force_urlencoded)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Internal: Perform an HTTP PUT request.
|
27
|
+
def put(path, options={}, authenticate=true, raw=false, version=api_version, force_urlencoded=false)
|
28
|
+
request(:put, path, options, authenticate, raw, version, force_urlencoded)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Internal: Gets the user requests limit.
|
32
|
+
def ratelimit
|
33
|
+
headers = get("/ratelimit",{}, true, true).headers
|
34
|
+
return headers["X-RateLimit-Limit"].to_i
|
35
|
+
end
|
36
|
+
alias rate_limit ratelimit
|
37
|
+
|
38
|
+
# Internal: Gets the remaining user requests.
|
39
|
+
def ratelimit_remaining
|
40
|
+
headers = get("/ratelimit",{}, api_version, true, true).headers
|
41
|
+
return headers["X-RateLimit-Remaining"].to_i
|
42
|
+
end
|
43
|
+
alias rate_limit_remaining ratelimit_remaining
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# Internal: Perform the HTTP request.
|
48
|
+
#
|
49
|
+
# method - The Symbol representing the HTTP method.
|
50
|
+
# path - The String URI to call.
|
51
|
+
# options - The Hash options containing the params to send to the
|
52
|
+
# service.
|
53
|
+
# authenticate - The Boolean value that authenticate the user.
|
54
|
+
# raw - The Boolean value let return the complete response.
|
55
|
+
# force_urlencoded - The Boolean value that force the url encoding.
|
56
|
+
def request(method, path, options, authenticate, raw, version, force_urlencoded)
|
57
|
+
response = connection(authenticate, raw, version, force_urlencoded).send(method) do |request|
|
58
|
+
case method
|
59
|
+
when :delete, :get
|
60
|
+
request.url(path, options)
|
61
|
+
when :patch, :post, :put
|
62
|
+
request.url(path)
|
63
|
+
request.body = options unless options.empty?
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
raw ? response : response.body
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Lelylan
|
2
|
+
class Version
|
3
|
+
MAJOR = 0 unless defined? MAJOR
|
4
|
+
MINOR = 0 unless defined? MINOR
|
5
|
+
PATCH = 1 unless defined? PATCH
|
6
|
+
PRE = nil unless defined? PRE
|
7
|
+
|
8
|
+
class << self
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
[MAJOR, MINOR, PATCH, PRE].compact.join('.')
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require 'helper'
|
3
|
+
|
4
|
+
describe Faraday::Response do
|
5
|
+
|
6
|
+
let(:client) do
|
7
|
+
Lelylan::Client.new
|
8
|
+
end
|
9
|
+
|
10
|
+
{
|
11
|
+
400 => Lelylan::BadRequest,
|
12
|
+
401 => Lelylan::Unauthorized,
|
13
|
+
403 => Lelylan::Forbidden,
|
14
|
+
404 => Lelylan::NotFound,
|
15
|
+
406 => Lelylan::NotAcceptable,
|
16
|
+
422 => Lelylan::UnprocessableEntity,
|
17
|
+
500 => Lelylan::InternalServerError,
|
18
|
+
501 => Lelylan::NotImplemented,
|
19
|
+
502 => Lelylan::BadGateway,
|
20
|
+
503 => Lelylan::ServiceUnavailable,
|
21
|
+
}.each do |status, exception|
|
22
|
+
|
23
|
+
context "when HTTP status is #{status}" do
|
24
|
+
|
25
|
+
before do
|
26
|
+
stub_get('http://api.lelylan.com/devices').
|
27
|
+
to_return(:status => status)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should raise #{exception.name} error" do
|
31
|
+
expect do
|
32
|
+
client.devices
|
33
|
+
end.to raise_error(exception)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
{
|
2
|
+
"uri": "http://www.example.com/consumptions/5003c54dd033a96a3c0000fc",
|
3
|
+
"id": "5003c54dd033a96a3c0000fc",
|
4
|
+
"device": {
|
5
|
+
"uri": "http://www.example.com/devices/5003c54dd033a96a3c0000f8"
|
6
|
+
},
|
7
|
+
"type": "instantaneous",
|
8
|
+
"value": 125.0,
|
9
|
+
"unit": "kwh",
|
10
|
+
"occur_at": "2012-07-09T09:39:57+02:00"
|
11
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
[{
|
2
|
+
"uri": "http://www.example.com/consumptions/5003c54ad033a96a3c00000b",
|
3
|
+
"id": "5003c54ad033a96a3c00000b",
|
4
|
+
"device": {
|
5
|
+
"uri": "http://www.example.com/devices/5003c54ad033a96a3c000007"
|
6
|
+
},
|
7
|
+
"type": "instantaneous",
|
8
|
+
"value": 125.0,
|
9
|
+
"unit": "kwh",
|
10
|
+
"occur_at": "2012-07-09T09:39:54+02:00"
|
11
|
+
}]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
{
|
2
|
+
"uri": "http://www.example.com/devices/5003c611d033a96b96000271",
|
3
|
+
"id": "5003c611d033a96b96000271",
|
4
|
+
"name": "Closet dimmer",
|
5
|
+
"type": {
|
6
|
+
"uri": "https://type.lelylan.com/types/dimmer"
|
7
|
+
},
|
8
|
+
"properties": [{
|
9
|
+
"uri": "https://type.lelylan.com/properties/status",
|
10
|
+
"value": "off"
|
11
|
+
},
|
12
|
+
{
|
13
|
+
"uri": "https://type.lelylan.com/properties/intensity",
|
14
|
+
"value": "0.0"
|
15
|
+
}],
|
16
|
+
"physical": {
|
17
|
+
"uri": "https://node.lelylan.com/devices/physical-dimmer"
|
18
|
+
},
|
19
|
+
"pending": {
|
20
|
+
"uri": "http://www.example.com/devices/5003c611d033a96b96000271/pending",
|
21
|
+
"status": "false"
|
22
|
+
},
|
23
|
+
"created_at": "2012-07-16T09:43:13+02:00",
|
24
|
+
"updated_at": "2012-07-16T09:43:13+02:00"
|
25
|
+
}
|
@@ -0,0 +1,25 @@
|
|
1
|
+
[{
|
2
|
+
"uri": "http://www.example.com/devices/5003c60ed033a96b96000009",
|
3
|
+
"id": "5003c60ed033a96b96000009",
|
4
|
+
"name": "Closet dimmer",
|
5
|
+
"type": {
|
6
|
+
"uri": "https://type.lelylan.com/types/dimmer"
|
7
|
+
},
|
8
|
+
"properties": [{
|
9
|
+
"uri": "https://type.lelylan.com/properties/status",
|
10
|
+
"value": "off"
|
11
|
+
},
|
12
|
+
{
|
13
|
+
"uri": "https://type.lelylan.com/properties/intensity",
|
14
|
+
"value": "0.0"
|
15
|
+
}],
|
16
|
+
"physical": {
|
17
|
+
"uri": "https://node.lelylan.com/devices/physical-dimmer"
|
18
|
+
},
|
19
|
+
"pending": {
|
20
|
+
"uri": "http://www.example.com/devices/5003c60ed033a96b96000009/pending",
|
21
|
+
"status": false
|
22
|
+
},
|
23
|
+
"created_at": "2012-07-16T09:43:10+02:00",
|
24
|
+
"updated_at": "2012-07-16T09:43:10+02:00"
|
25
|
+
}]
|
@@ -0,0 +1,15 @@
|
|
1
|
+
{
|
2
|
+
"uri": "http://www.example.com/functions/500425ffd033a9b4ac0002e5",
|
3
|
+
"id": "500425ffd033a9b4ac0002e5",
|
4
|
+
"name": "Set intensity",
|
5
|
+
"created_at": "2012-07-16T14:32:31Z",
|
6
|
+
"updated_at": "2012-07-16T14:32:31Z",
|
7
|
+
"properties": [{
|
8
|
+
"uri": "http://www.example.com/properties/status",
|
9
|
+
"value": "on"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"uri": "http://www.example.com/properties/intensity",
|
13
|
+
"value": "0"
|
14
|
+
}]
|
15
|
+
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
[{
|
2
|
+
"uri": "http://www.example.com/functions/500425fbd033a9b4ac0000db",
|
3
|
+
"id": "500425fbd033a9b4ac0000db",
|
4
|
+
"name": "Set intensity",
|
5
|
+
"created_at": "2012-07-16T14:32:27Z",
|
6
|
+
"updated_at": "2012-07-16T14:32:27Z",
|
7
|
+
"properties": [{
|
8
|
+
"uri": "http://www.example.com/properties/status",
|
9
|
+
"value": "on"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"uri": "http://www.example.com/properties/intensity",
|
13
|
+
"value": "0"
|
14
|
+
}]
|
15
|
+
}]
|
@@ -0,0 +1,16 @@
|
|
1
|
+
[{
|
2
|
+
"uri": "http://www.example.com/histories/5003c54fd033a96a3c0001a7",
|
3
|
+
"id": "5003c54fd033a96a3c0001a7",
|
4
|
+
"device": {
|
5
|
+
"uri": "http://www.example.com/devices/5003c54fd033a96a3c0001a3"
|
6
|
+
},
|
7
|
+
"properties": [{
|
8
|
+
"uri": "https://type.lelylan.com/properties/intensity",
|
9
|
+
"value": "100.0"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"uri": "https://type.lelylan.com/properties/status",
|
13
|
+
"value": "on"
|
14
|
+
}],
|
15
|
+
"created_at": "2012-07-09T09:39:59+02:00"
|
16
|
+
}]
|
@@ -0,0 +1,16 @@
|
|
1
|
+
{
|
2
|
+
"uri": "http://www.example.com/histories/5003c552d033a96a3c000398",
|
3
|
+
"id": "5003c552d033a96a3c000398",
|
4
|
+
"device": {
|
5
|
+
"uri": "http://www.example.com/devices/5003c552d033a96a3c000394"
|
6
|
+
},
|
7
|
+
"properties": [{
|
8
|
+
"uri": "https://type.lelylan.com/properties/intensity",
|
9
|
+
"value": "100.0"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"uri": "https://type.lelylan.com/properties/status",
|
13
|
+
"value": "on"
|
14
|
+
}],
|
15
|
+
"created_at": "2012-07-09T09:40:02+02:00"
|
16
|
+
}
|
@@ -0,0 +1,55 @@
|
|
1
|
+
{
|
2
|
+
"uri": "http://www.example.com/locations/1",
|
3
|
+
"id": "1",
|
4
|
+
"name": "Floor",
|
5
|
+
"type": "floor",
|
6
|
+
"created_at": "2012-07-16T17:04:14Z",
|
7
|
+
"updated_at": "2012-07-16T17:04:14Z",
|
8
|
+
"locations": {
|
9
|
+
"parent": {
|
10
|
+
"uri": "http://www.example.com/locations/2"
|
11
|
+
},
|
12
|
+
"children": [{
|
13
|
+
"uri": "http://www.example.com/locations/4"
|
14
|
+
}],
|
15
|
+
"ancestors": [{
|
16
|
+
"uri": "http://www.example.com/locations/3"
|
17
|
+
},
|
18
|
+
{
|
19
|
+
"uri": "http://www.example.com/locations/2"
|
20
|
+
}],
|
21
|
+
"descendants": [{
|
22
|
+
"uri": "http://www.example.com/locations/4"
|
23
|
+
},
|
24
|
+
{
|
25
|
+
"uri": "http://www.example.com/locations/5"
|
26
|
+
}]
|
27
|
+
},
|
28
|
+
"devices": {
|
29
|
+
"children": [{
|
30
|
+
"uri": {
|
31
|
+
"uri": "https://device.lelylan.com/devices/closet-dimmer"
|
32
|
+
}
|
33
|
+
},
|
34
|
+
{
|
35
|
+
"uri": {
|
36
|
+
"uri": "https://device.lelylan.com/devices/another-closet-dimmer"
|
37
|
+
}
|
38
|
+
}],
|
39
|
+
"descendants": [{
|
40
|
+
"uri": {
|
41
|
+
"uri": "https://device.lelylan.com/devices/closet-dimmer"
|
42
|
+
}
|
43
|
+
},
|
44
|
+
{
|
45
|
+
"uri": {
|
46
|
+
"uri": "https://device.lelylan.com/devices/another-closet-dimmer"
|
47
|
+
}
|
48
|
+
},
|
49
|
+
{
|
50
|
+
"uri": {
|
51
|
+
"uri": "https://device.lelylan.com/devices/descendant-closet-dimmer"
|
52
|
+
}
|
53
|
+
}]
|
54
|
+
}
|
55
|
+
}
|