gooddata 0.6.7 → 0.6.8

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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -1
  3. data/README.md +10 -2
  4. data/TODO.md +32 -0
  5. data/gooddata.gemspec +5 -0
  6. data/lib/gooddata.rb +4 -0
  7. data/lib/gooddata/app/app.rb +12 -0
  8. data/lib/gooddata/bricks/middleware/gooddata_middleware.rb +4 -3
  9. data/lib/gooddata/bricks/middleware/restforce_middleware.rb +2 -1
  10. data/lib/gooddata/cli/commands/console_cmd.rb +23 -5
  11. data/lib/gooddata/cli/commands/domain_cmd.rb +9 -10
  12. data/lib/gooddata/cli/commands/process_cmd.rb +11 -9
  13. data/lib/gooddata/cli/commands/project_cmd.rb +25 -27
  14. data/lib/gooddata/cli/commands/projects_cmd.rb +2 -2
  15. data/lib/gooddata/cli/commands/run_ruby_cmd.rb +1 -1
  16. data/lib/gooddata/cli/commands/user_cmd.rb +2 -2
  17. data/lib/gooddata/cli/hooks.rb +4 -2
  18. data/lib/gooddata/cli/shared.rb +1 -1
  19. data/lib/gooddata/cli/terminal.rb +1 -1
  20. data/lib/gooddata/commands/api.rb +1 -1
  21. data/lib/gooddata/commands/auth.rb +4 -28
  22. data/lib/gooddata/commands/domain.rb +9 -4
  23. data/lib/gooddata/commands/process.rb +26 -23
  24. data/lib/gooddata/commands/project.rb +74 -50
  25. data/lib/gooddata/commands/projects.rb +3 -2
  26. data/lib/gooddata/commands/role.rb +9 -3
  27. data/lib/gooddata/commands/user.rb +6 -4
  28. data/lib/gooddata/connection.rb +11 -45
  29. data/lib/gooddata/core/logging.rb +0 -1
  30. data/lib/gooddata/core/project.rb +22 -22
  31. data/lib/gooddata/core/rest.rb +9 -8
  32. data/lib/gooddata/core/user.rb +0 -11
  33. data/lib/gooddata/exceptions/project_not_found.rb +1 -0
  34. data/lib/gooddata/extensions/enumerable.rb +10 -0
  35. data/lib/gooddata/extensions/hash.rb +25 -0
  36. data/lib/gooddata/goodzilla/goodzilla.rb +4 -4
  37. data/lib/gooddata/helper/class_helper.rb +1 -0
  38. data/lib/gooddata/helper/helpers.rb +8 -0
  39. data/lib/gooddata/helpers/auth_helpers.rb +41 -0
  40. data/lib/gooddata/mixins/author.rb +1 -1
  41. data/lib/gooddata/mixins/contributor.rb +1 -1
  42. data/lib/gooddata/mixins/data_property_reader.rb +2 -0
  43. data/lib/gooddata/mixins/data_property_writer.rb +2 -0
  44. data/lib/gooddata/mixins/inspector.rb +49 -0
  45. data/lib/gooddata/mixins/md_finders.rb +16 -8
  46. data/lib/gooddata/mixins/md_id_to_uri.rb +12 -4
  47. data/lib/gooddata/mixins/md_object_indexer.rb +15 -4
  48. data/lib/gooddata/mixins/md_object_query.rb +42 -20
  49. data/lib/gooddata/mixins/md_relations.rb +21 -12
  50. data/lib/gooddata/mixins/meta_getter.rb +2 -0
  51. data/lib/gooddata/mixins/meta_property_reader.rb +2 -0
  52. data/lib/gooddata/mixins/meta_property_writer.rb +2 -0
  53. data/lib/gooddata/mixins/rest_resource.rb +32 -10
  54. data/lib/gooddata/mixins/root_key_getter.rb +1 -1
  55. data/lib/gooddata/models/data_result.rb +3 -1
  56. data/lib/gooddata/models/domain.rb +31 -22
  57. data/lib/gooddata/models/empty_result.rb +22 -0
  58. data/lib/gooddata/models/invitation.rb +11 -9
  59. data/lib/gooddata/models/links.rb +5 -3
  60. data/lib/gooddata/models/membership.rb +23 -28
  61. data/lib/gooddata/models/metadata.rb +35 -35
  62. data/lib/gooddata/models/metadata/attribute.rb +10 -8
  63. data/lib/gooddata/models/metadata/dashboard.rb +1 -1
  64. data/lib/gooddata/models/metadata/fact.rb +3 -3
  65. data/lib/gooddata/models/metadata/label.rb +4 -4
  66. data/lib/gooddata/models/metadata/metric.rb +76 -38
  67. data/lib/gooddata/models/metadata/report.rb +52 -17
  68. data/lib/gooddata/models/metadata/report_definition.rb +178 -28
  69. data/lib/gooddata/models/model.rb +13 -6
  70. data/lib/gooddata/models/process.rb +93 -30
  71. data/lib/gooddata/models/profile.rb +18 -20
  72. data/lib/gooddata/models/project.rb +344 -127
  73. data/lib/gooddata/models/project_creator.rb +32 -22
  74. data/lib/gooddata/models/project_metadata.rb +26 -14
  75. data/lib/gooddata/models/project_role.rb +15 -17
  76. data/lib/gooddata/models/report_data_result.rb +4 -0
  77. data/lib/gooddata/models/schedule.rb +51 -20
  78. data/lib/gooddata/models/schema_blueprint.rb +9 -3
  79. data/lib/gooddata/rest/README.md +37 -0
  80. data/lib/gooddata/rest/client.rb +318 -0
  81. data/lib/gooddata/rest/connection.rb +235 -0
  82. data/lib/gooddata/rest/connections/connections.rb +8 -0
  83. data/lib/gooddata/rest/connections/dummy_connection.rb +52 -0
  84. data/lib/gooddata/rest/connections/rest_client_connection.rb +177 -0
  85. data/lib/gooddata/rest/object.rb +32 -0
  86. data/lib/gooddata/rest/object_factory.rb +67 -0
  87. data/lib/gooddata/rest/resource.rb +17 -0
  88. data/lib/gooddata/rest/rest.rb +20 -0
  89. data/lib/gooddata/version.rb +1 -1
  90. data/spec/data/cc/data/source/commits.csv +4 -0
  91. data/spec/data/cc/data/source/devs.csv +4 -0
  92. data/spec/data/cc/data/source/repos.csv +3 -0
  93. data/spec/data/cc/devel.prm +0 -0
  94. data/spec/data/cc/graph/graph.grf +11 -0
  95. data/spec/data/cc/workspace.prm +19 -0
  96. data/spec/data/hello_world_process/hello_world.rb +1 -0
  97. data/spec/data/hello_world_process/hello_world.zip +0 -0
  98. data/spec/data/users.csv +12 -12
  99. data/spec/helpers/connection_helper.rb +6 -0
  100. data/spec/helpers/process_helper.rb +12 -0
  101. data/spec/helpers/project_helper.rb +2 -2
  102. data/spec/integration/command_projects_spec.rb +11 -9
  103. data/spec/integration/create_from_template_spec.rb +6 -2
  104. data/spec/integration/full_process_schedule_spec.rb +49 -36
  105. data/spec/integration/full_project_spec.rb +221 -256
  106. data/spec/integration/partial_md_export_import_spec.rb +18 -17
  107. data/spec/logging_in_logging_out_spec.rb +17 -8
  108. data/spec/spec_helper.rb +4 -2
  109. data/spec/unit/cli/commands/cmd_api_spec.rb +1 -1
  110. data/spec/unit/cli/commands/cmd_auth_spec.rb +1 -1
  111. data/spec/unit/cli/commands/cmd_domain_spec.rb +29 -3
  112. data/spec/unit/cli/commands/cmd_process_spec.rb +1 -1
  113. data/spec/unit/cli/commands/cmd_project_spec.rb +1 -1
  114. data/spec/unit/cli/commands/cmd_role_spec.rb +13 -2
  115. data/spec/unit/cli/commands/cmd_run_ruby_spec.rb +1 -1
  116. data/spec/unit/cli/commands/cmd_scaffold_spec.rb +1 -1
  117. data/spec/unit/cli/commands/cmd_user_spec.rb +1 -1
  118. data/spec/unit/commands/command_api_spec.rb +0 -19
  119. data/spec/unit/commands/command_auth_spec.rb +20 -13
  120. data/spec/unit/commands/command_dataset_spec.rb +2 -2
  121. data/spec/unit/commands/command_process_spec.rb +24 -21
  122. data/spec/unit/commands/command_projects_spec.rb +2 -2
  123. data/spec/unit/commands/command_scaffold_spec.rb +2 -2
  124. data/spec/unit/commands/command_user_spec.rb +3 -3
  125. data/spec/unit/core/connection_spec.rb +9 -10
  126. data/spec/unit/core/project_spec.rb +8 -4
  127. data/spec/unit/core/rest_spec.rb +6 -6
  128. data/spec/unit/models/domain_spec.rb +14 -7
  129. data/spec/unit/models/invitation_spec.rb +2 -2
  130. data/spec/unit/models/membership_spec.rb +5 -5
  131. data/spec/unit/models/metric_spec.rb +92 -0
  132. data/spec/unit/models/profile_spec.rb +25 -21
  133. data/spec/unit/models/project_blueprint_spec.rb +6 -6
  134. data/spec/unit/models/project_role_spec.rb +3 -5
  135. data/spec/unit/models/project_spec.rb +43 -37
  136. data/spec/unit/models/schedule_spec.rb +58 -107
  137. data/spec/unit/rest/resource_spec.rb +6 -0
  138. metadata +87 -10
  139. data/lib/gooddata/cli/commands/role_cmd.rb +0 -28
  140. data/lib/gooddata/core/connection.rb +0 -392
  141. data/lib/gooddata/core/threaded.rb +0 -14
  142. data/lib/gooddata/models/md_object.rb +0 -25
  143. data/lib/gooddata/models/metadata/folder.rb +0 -24
  144. data/spec/unit/models/md_object_spec.rb +0 -55
  145. data/spec/unit/models/metric.rb +0 -92
@@ -1,28 +0,0 @@
1
- # encoding: UTF-8
2
-
3
- require 'pp'
4
-
5
- require_relative '../shared'
6
- require_relative '../../commands/role'
7
-
8
- GoodData::CLI.module_eval do
9
- desc 'Basic Role Management'
10
- arg_name 'list'
11
- command :role do |c|
12
- c.desc 'List roles'
13
- c.command :list do |list|
14
- list.action do |global_options, options, args|
15
- opts = options.merge(global_options)
16
- GoodData.connect(opts)
17
-
18
- pid = global_options[:project_id]
19
- fail 'Project ID has to be provided' if pid.nil? || pid.empty?
20
-
21
- role_list = GoodData::Command::Role.list(pid)
22
- role_list.each do |k, v|
23
- puts [k, v[:uri]].join(',')
24
- end
25
- end
26
- end
27
- end
28
- end
@@ -1,392 +0,0 @@
1
- # encoding: UTF-8
2
-
3
- require 'multi_json'
4
- require 'rest-client'
5
-
6
- require_relative '../version'
7
-
8
- module GoodData
9
- # # GoodData HTTP wrapper
10
- #
11
- # Provides a convenient HTTP wrapper for talking with the GoodData API.
12
- #
13
- # Remember that the connection is shared amongst the entire application.
14
- # Therefore you can't be logged in to more than _one_ GoodData account.
15
- # per session. Simultaneous connections to multiple GoodData accounts is not
16
- # supported at this time.
17
- #
18
- # The GoodData API is a RESTful API that communicates using JSON. This wrapper
19
- # makes sure that the session is stored between requests and that the JSON is
20
- # parsed both when sending and receiving.
21
- #
22
- # ## Usage
23
- #
24
- # Before a connection can be made to the GoodData API, you have to supply the user credentials like this:
25
- #
26
- # Connection.new(username, password)
27
- #
28
- # To send a HTTP request use either the get, post or delete methods documented below.
29
- #
30
- class Connection
31
- DEFAULT_URL = 'https://secure.gooddata.com'
32
- LOGIN_PATH = '/gdc/account/login'
33
- TOKEN_PATH = '/gdc/account/token'
34
-
35
- attr_reader(:auth_token, :url)
36
- attr_accessor :status, :options
37
-
38
- # Options:
39
- # * :tries - Number of retries to perform. Defaults to 1.
40
- # * :on - The Exception on which a retry will be performed. Defaults to Exception, which retries on any Exception.
41
- #
42
- # ### Example
43
- #
44
- # retryable(:tries => 1, :on => OpenURI::HTTPError) do
45
- # # your code here
46
- # end
47
- #
48
- def retryable(options = {}, &block)
49
- opts = { :tries => 1, :on => Exception }.merge(options)
50
-
51
- retry_exception, retries = opts[:on], opts[:tries]
52
-
53
- begin
54
- return yield
55
- rescue retry_exception
56
- retry if (retries -= 1) > 0
57
- end
58
-
59
- yield
60
- end
61
-
62
- # Set the GoodData account credentials.
63
- #
64
- # This have to be performed before any calls to the API.
65
- #
66
- # @param username The GoodData account username
67
- # @param password The GoodData account password
68
- #
69
- def initialize(username, password, options = {})
70
- @status = :not_connected
71
- @username = username
72
- @password = password
73
- @url = options[:server] || DEFAULT_URL
74
- @auth_token = options[:gdc_temporary_token]
75
- @options = options
76
-
77
- @headers = options[:headers] || {}
78
-
79
- default_headers = {
80
- :content_type => :json,
81
- :accept => [:json, :zip],
82
- :user_agent => GoodData.gem_version_string
83
- }
84
- default_headers.merge! @headers
85
-
86
- @server = RestClient::Resource.new @url,
87
- :timeout => @options[:timeout],
88
- :headers => default_headers
89
-
90
- @server = create_server_connection(@url, @options)
91
- end
92
-
93
- # Returns the user JSON object of the currently logged in GoodData user account.
94
- def user
95
- ensure_connection
96
- @user
97
- end
98
-
99
- # Performs a HTTP GET request.
100
- #
101
- # Retuns the JSON response formatted as a Hash object.
102
- #
103
- # @param path The HTTP path on the GoodData server (must be prefixed with a forward slash)
104
- #
105
- # ### Examples
106
- #
107
- # Connection.new(username, password).get '/gdc/projects'
108
- #
109
- def get(path, options = {})
110
- GoodData.logger.debug "GET #{@server}#{path}"
111
- ensure_connection
112
- b = proc { @server[path].get cookies }
113
- process_response(options, &b)
114
- end
115
-
116
- # Performs a HTTP POST request.
117
- #
118
- # Retuns the JSON response formatted as a Hash object.
119
- #
120
- # @param path The HTTP path on the GoodData server (must be prefixed with a forward slash)
121
- # @param data The payload data in the format of a Hash object
122
- #
123
- # ### Examples
124
- #
125
- # Connection.new(username, password).post '/gdc/projects', { ... }
126
- #
127
- def post(path, data, options = {})
128
- GoodData.logger.debug("POST #{@server}#{path}, payload: #{scrub_params(data, [:password, :login, :authorizationToken])}")
129
- ensure_connection
130
- payload = data.is_a?(Hash) ? data.to_json : data
131
- b = proc { @server[path].post payload, cookies }
132
- process_response(options, &b)
133
- end
134
-
135
- # Performs a HTTP PUT request.
136
- #
137
- # Retuns the JSON response formatted as a Hash object.
138
- #
139
- # @param path The HTTP path on the GoodData server (must be prefixed with a forward slash)
140
- # @param data The payload data in the format of a Hash object
141
- #
142
- # ### Examples
143
- #
144
- # Connection.new(username, password).put '/gdc/projects', { ... }
145
- #
146
- def put(path, data, options = {})
147
- payload = data.is_a?(Hash) ? data.to_json : data
148
- GoodData.logger.debug "PUT #{@server}#{path}, payload: #{payload}"
149
- ensure_connection
150
- b = proc { @server[path].put payload, cookies }
151
- process_response(options, &b)
152
- end
153
-
154
- # Performs a HTTP DELETE request.
155
- #
156
- # Retuns the JSON response formatted as a Hash object.
157
- #
158
- # @param path The HTTP path on the GoodData server (must be prefixed with a forward slash)
159
- #
160
- # ### Examples
161
- #
162
- # Connection.new(username, password).delete '/gdc/project/1'
163
- #
164
- def delete(path, options = {})
165
- GoodData.logger.debug "DELETE #{@server}#{path}"
166
- ensure_connection
167
- b = proc { @server[path].delete cookies }
168
- process_response(options, &b)
169
- end
170
-
171
- # Get the cookies associated with the current connection.
172
- def cookies
173
- @cookies ||= { :cookies => {} }
174
- end
175
-
176
- # Set the cookies used when communicating with the GoodData API.
177
- def merge_cookies!(cookies)
178
- self.cookies
179
- @cookies[:cookies].merge! cookies
180
- end
181
-
182
- # Returns true if a connection have been established to the GoodData API
183
- # and the login was successful.
184
- def logged_in?
185
- @status == :logged_in
186
- end
187
-
188
- def url=(url = nil)
189
- @url = url || DEFAULT_URL
190
- @server = create_server_connection(@url, @options)
191
- end
192
-
193
- # The connection will automatically be established once it's needed, which it
194
- # usually is when either the user, get, post or delete method is called. If you
195
- # want to force a connection (or a re-connect) you can use this method.
196
- def connect!
197
- connect
198
- end
199
-
200
- # Uploads a file to GoodData server
201
- # /uploads/ resources are special in that they use a different
202
- # host and a basic authentication.
203
- def upload(file, options = {})
204
- ensure_connection
205
-
206
- dir = options[:directory] || ''
207
- staging_uri = options[:staging_url].to_s
208
- url = dir.empty? ? staging_uri : URI.join(staging_uri, "#{dir}/").to_s
209
-
210
- # Make a directory, if needed
211
- unless dir.empty?
212
- method = :get
213
- GoodData.logger.debug "#{method}: #{url}"
214
- begin
215
- # first check if it does exits
216
- RestClient::Request.execute({
217
- :method => method,
218
- :url => url,
219
- :timeout => @options[:timeout],
220
- :headers => {
221
- :user_agent => GoodData.gem_version_string
222
- }
223
- }.merge(cookies)
224
- )
225
- rescue RestClient::Exception => e
226
- if e.http_code == 404
227
- method = :mkcol
228
- GoodData.logger.debug "#{method}: #{url}"
229
- RestClient::Request.execute({
230
- :method => method,
231
- :url => url,
232
- :timeout => @options[:timeout],
233
- :headers => {
234
- :user_agent => GoodData.gem_version_string
235
- }
236
- }.merge(cookies))
237
- end
238
- end
239
- end
240
-
241
- payload = options[:stream] ? 'file' : File.read(file)
242
- filename = options[:filename] || options[:stream] ? 'randome-filename.txt' : File.basename(file)
243
-
244
- # Upload the file
245
- # puts "uploading the file #{URI.join(url, filename).to_s}"
246
- req = RestClient::Request.new(
247
- :method => :put,
248
- :url => URI.join(url, filename).to_s,
249
- :timeout => @options[:timeout],
250
- :headers => {
251
- :user_agent => GoodData.gem_version_string
252
- },
253
- :payload => payload,
254
- :raw_response => true,
255
- :user => @username,
256
- :password => @password
257
- )
258
- # .merge(cookies))
259
- req.execute
260
- true
261
- end
262
-
263
- def download(what, where, options = {})
264
- staging_uri = options[:staging_url].to_s
265
- url = staging_uri + what
266
- req = RestClient::Request.new(
267
- :method => 'GET',
268
- :url => url,
269
- :user => @username,
270
- :password => @password)
271
-
272
- if where.is_a?(String)
273
- File.open(where, 'w') do |f|
274
- req.execute do |chunk, x, y|
275
- f.write chunk
276
- end
277
- end
278
- else
279
- # Assume it is a IO stream
280
- req.execute do |chunk, x, y|
281
- where.write chunk
282
- end
283
- end
284
- end
285
-
286
- def connected?
287
- @status == :logged_in
288
- end
289
-
290
- def disconnect
291
- if connected? && GoodData.connection.user['state']
292
- GoodData.delete(GoodData.connection.user['state'])
293
- @status = :not_connected
294
- end
295
- end
296
-
297
- private
298
-
299
- def create_server_connection(url, options)
300
- RestClient::Resource.new url,
301
- :timeout => options[:timeout],
302
- :headers => {
303
- :content_type => :json,
304
- :accept => [:json, :zip],
305
- :user_agent => GoodData.gem_version_string
306
- }
307
- end
308
-
309
- def ensure_connection
310
- connect if @status == :not_connected
311
- end
312
-
313
- def connect
314
- GoodData.logger.info 'Connecting to GoodData...'
315
- @status = :connecting
316
- authenticate
317
- end
318
-
319
- def authenticate
320
- credentials = {
321
- 'postUserLogin' => {
322
- 'login' => @username,
323
- 'password' => @password,
324
- 'remember' => 1
325
- }
326
- }
327
- GoodData.logger.debug 'Logging in...'
328
- @user = post(LOGIN_PATH, credentials, :dont_reauth => true)['userLogin']
329
- refresh_token :dont_reauth => true # avoid infinite loop if refresh_token fails with 401
330
-
331
- @status = :logged_in
332
- end
333
-
334
- def process_response(options = {}, &block)
335
- begin
336
- response = block.call
337
- rescue RestClient::Unauthorized
338
- raise $ERROR_INFO if options[:dont_reauth]
339
- refresh_token
340
- response = block.call
341
- end
342
- merge_cookies! response.cookies
343
- content_type = response.headers[:content_type]
344
- return response if options[:process] == false
345
-
346
- if content_type == 'application/json' || content_type == 'application/json;charset=UTF-8'
347
- result = response.to_str == '""' ? {} : MultiJson.load(response.to_str)
348
- GoodData.logger.debug "Response: #{result.inspect}"
349
- elsif ['text/plain;charset=UTF-8', 'text/plain; charset=UTF-8', 'text/plain'].include?(content_type)
350
- result = response
351
- GoodData.logger.debug 'Response: plain text'
352
- elsif content_type == 'application/zip'
353
- result = response
354
- GoodData.logger.debug 'Response: a zipped stream'
355
- elsif response.headers[:content_length].to_s == '0'
356
- result = nil
357
- GoodData.logger.debug 'Response: Empty response possibly 204'
358
- elsif response.code == 204
359
- result = nil
360
- GoodData.logger.debug 'Response: 204 no content'
361
- else
362
- fail "Unsupported response content type '%s':\n%s" % [content_type, response.to_str[0..127]]
363
- end
364
- result
365
- rescue RestClient::Exception => e
366
- GoodData.logger.debug "Response: #{e.response}"
367
- raise $ERROR_INFO
368
- end
369
-
370
- def refresh_token(options = {})
371
- GoodData.logger.debug 'Getting authentication token...'
372
- begin
373
- get TOKEN_PATH, :dont_reauth => true # avoid infinite loop GET fails with 401
374
- rescue RestClient::Unauthorized
375
- raise $ERROR_INFO if options[:dont_reauth]
376
- authenticate
377
- end
378
- end
379
-
380
- def scrub_params(params, keys)
381
- keys = keys.reduce([]) { |a, e| a.concat([e.to_s, e.to_sym]) }
382
-
383
- new_params = params.deep_dup
384
- GoodData::Helpers.hash_dfs(new_params) do |k, key|
385
- keys.each do |key_to_scrub|
386
- k[key_to_scrub] = ('*' * k[key_to_scrub].length) if k && k.key?(key_to_scrub) && k[key_to_scrub]
387
- end
388
- end
389
- new_params
390
- end
391
- end
392
- end
@@ -1,14 +0,0 @@
1
- # encoding: UTF-8
2
-
3
- module GoodData
4
- module Threaded
5
- # Used internally for thread safety
6
- def threaded
7
- Thread.current[:goooddata] ||= {}
8
- end
9
- end
10
-
11
- class << self
12
- include Threaded
13
- end
14
- end
@@ -1,25 +0,0 @@
1
- # encoding: UTF-8
2
-
3
- module GoodData
4
- module Model
5
- class MdObject
6
- attr_accessor :name, :title
7
-
8
- def visual
9
- "TITLE \"#{title_esc}\""
10
- end
11
-
12
- def title_esc
13
- title.gsub(/"/, "\\\"")
14
- end
15
-
16
- ##
17
- # Generates an identifier from the object name by transliterating
18
- # non-Latin character and then dropping non-alphanumerical characters.
19
- #
20
- def identifier
21
- @identifier ||= "#{type_prefix}.#{name}"
22
- end
23
- end
24
- end
25
- end