ovirt-engine-sdk 4.0.1 → 4.4.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.
- checksums.yaml +5 -5
- data/CHANGES.adoc +684 -0
- data/README.adoc +729 -32
- data/ext/ovirtsdk4c/extconf.rb +31 -5
- data/ext/ovirtsdk4c/ov_error.c +9 -2
- data/ext/ovirtsdk4c/ov_error.h +3 -1
- data/ext/ovirtsdk4c/ov_http_client.c +1218 -0
- data/ext/ovirtsdk4c/ov_http_client.h +75 -0
- data/ext/ovirtsdk4c/ov_http_request.c +397 -0
- data/ext/ovirtsdk4c/ov_http_request.h +54 -0
- data/ext/ovirtsdk4c/ov_http_response.c +210 -0
- data/ext/ovirtsdk4c/ov_http_response.h +41 -0
- data/ext/ovirtsdk4c/ov_http_transfer.c +91 -0
- data/ext/ovirtsdk4c/ov_http_transfer.h +47 -0
- data/ext/ovirtsdk4c/ov_module.h +2 -2
- data/ext/ovirtsdk4c/ov_string.c +43 -0
- data/ext/ovirtsdk4c/ov_string.h +25 -0
- data/ext/ovirtsdk4c/ov_xml_reader.c +115 -99
- data/ext/ovirtsdk4c/ov_xml_reader.h +20 -3
- data/ext/ovirtsdk4c/ov_xml_writer.c +95 -77
- data/ext/ovirtsdk4c/ov_xml_writer.h +18 -3
- data/ext/ovirtsdk4c/ovirtsdk4c.c +10 -2
- data/lib/ovirtsdk4/connection.rb +695 -0
- data/lib/ovirtsdk4/errors.rb +70 -0
- data/lib/ovirtsdk4/probe.rb +324 -0
- data/lib/ovirtsdk4/reader.rb +74 -40
- data/lib/ovirtsdk4/readers.rb +3325 -976
- data/lib/ovirtsdk4/service.rb +439 -48
- data/lib/ovirtsdk4/services.rb +29365 -21180
- data/lib/ovirtsdk4/type.rb +20 -6
- data/lib/ovirtsdk4/types.rb +15048 -3198
- data/lib/ovirtsdk4/version.rb +1 -1
- data/lib/ovirtsdk4/writer.rb +108 -13
- data/lib/ovirtsdk4/writers.rb +1373 -294
- data/lib/ovirtsdk4.rb +4 -2
- metadata +88 -36
- data/lib/ovirtsdk4/http.rb +0 -548
data/lib/ovirtsdk4/service.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
#
|
2
|
-
# Copyright (c) 2015-
|
2
|
+
# Copyright (c) 2015-2017 Red Hat, Inc.
|
3
3
|
#
|
4
4
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
5
|
# you may not use this file except in compliance with the License.
|
@@ -15,43 +15,77 @@
|
|
15
15
|
#
|
16
16
|
|
17
17
|
module OvirtSDK4
|
18
|
+
#
|
19
|
+
# Instances of this class are returned for operatoins that specify the `wait: false` parameter.
|
20
|
+
#
|
21
|
+
class Future
|
22
|
+
#
|
23
|
+
# Creates a new future result.
|
24
|
+
#
|
25
|
+
# @param service [Service] The service that created this future.
|
26
|
+
# @param request [HttpRequest] The request that this future will wait for when the `wait` method is called.
|
27
|
+
# @param block [Block] The block that will be executed to check the response, and to convert its body into the
|
28
|
+
# right type of object.
|
29
|
+
#
|
30
|
+
# @api private
|
31
|
+
#
|
32
|
+
def initialize(service, request, &block)
|
33
|
+
@service = service
|
34
|
+
@request = request
|
35
|
+
@block = block
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# Waits till the result of the operation that created this future is available.
|
40
|
+
#
|
41
|
+
# @return [Object] The result of the operation that created this future.
|
42
|
+
#
|
43
|
+
def wait
|
44
|
+
response = @service.connection.wait(@request)
|
45
|
+
raise response if response.is_a?(Exception)
|
46
|
+
|
47
|
+
@block.call(response)
|
48
|
+
end
|
49
|
+
|
50
|
+
#
|
51
|
+
# Returns a string representation of the future.
|
52
|
+
#
|
53
|
+
# @return [String] The string representation.
|
54
|
+
#
|
55
|
+
def inspect
|
56
|
+
"#<#{self.class.name}:#{@request.method} #{@request.url}>"
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Returns a string representation of the future.
|
61
|
+
#
|
62
|
+
# @return [String] The string representation.
|
63
|
+
#
|
64
|
+
def to_s
|
65
|
+
inspect
|
66
|
+
end
|
67
|
+
end
|
18
68
|
|
19
69
|
#
|
20
70
|
# This is the base class for all the services of the SDK. It contains the utility methods used by all of them.
|
21
71
|
#
|
22
72
|
class Service
|
23
|
-
|
24
73
|
#
|
25
|
-
# Creates
|
74
|
+
# Creates a new implementation of the service.
|
26
75
|
#
|
27
|
-
#
|
28
|
-
#
|
76
|
+
# @param parent [Service, Connection] The parent of this service. For most services the parent will be another
|
77
|
+
# service. For example, for the `vm` service that manages virtual machine `123` the parent will be the `vms`
|
78
|
+
# service that manages the collection of virtual machines. For the root of the services tree the parent will
|
79
|
+
# be the connection.
|
80
|
+
#
|
81
|
+
# @param path [String] The path of this service, relative to its parent. For example, the path of the `vm`
|
82
|
+
# service that manages virtual machine `123` will be `vm/123`.
|
29
83
|
#
|
30
84
|
# @api private
|
31
85
|
#
|
32
|
-
def
|
33
|
-
|
34
|
-
|
35
|
-
unless fault.reason.nil?
|
36
|
-
message << ' ' unless message.empty?
|
37
|
-
message << "Fault reason is \"#{fault.reason}\"."
|
38
|
-
end
|
39
|
-
unless fault.detail.nil?
|
40
|
-
message << ' ' unless message.empty?
|
41
|
-
message << "Fault detail is \"#{fault.detail}\"."
|
42
|
-
end
|
43
|
-
end
|
44
|
-
unless response.nil?
|
45
|
-
unless response.code.nil?
|
46
|
-
message << ' ' unless message.empty?
|
47
|
-
message << "HTTP response code is #{response.code}."
|
48
|
-
end
|
49
|
-
unless response.message.nil?
|
50
|
-
message << ' ' unless message.empty?
|
51
|
-
message << "HTTP response message is \"#{response.message}\"."
|
52
|
-
end
|
53
|
-
end
|
54
|
-
raise Error.new(message)
|
86
|
+
def initialize(parent, path)
|
87
|
+
@parent = parent
|
88
|
+
@path = path
|
55
89
|
end
|
56
90
|
|
57
91
|
#
|
@@ -63,15 +97,9 @@ module OvirtSDK4
|
|
63
97
|
# @api private
|
64
98
|
#
|
65
99
|
def check_fault(response)
|
66
|
-
body = response
|
67
|
-
|
68
|
-
|
69
|
-
end
|
70
|
-
body = Reader.read(body)
|
71
|
-
if body.is_a?(Fault)
|
72
|
-
raise_error(response, body)
|
73
|
-
end
|
74
|
-
raise Error.new("Expected a fault, but got '#{body.class.name.split('::').last}'")
|
100
|
+
body = internal_read_body(response)
|
101
|
+
connection.raise_error(response, body) if body.is_a?(Fault)
|
102
|
+
raise Error, "Expected a fault, but got '#{body.class.name.split('::').last}'"
|
75
103
|
end
|
76
104
|
|
77
105
|
#
|
@@ -85,21 +113,384 @@ module OvirtSDK4
|
|
85
113
|
# @api private
|
86
114
|
#
|
87
115
|
def check_action(response)
|
88
|
-
body = response
|
89
|
-
|
90
|
-
raise_error(response, nil)
|
91
|
-
end
|
92
|
-
body = Reader.read(body)
|
93
|
-
if body.is_a?(Fault)
|
94
|
-
raise_error(response, body)
|
95
|
-
end
|
116
|
+
body = internal_read_body(response)
|
117
|
+
connection.raise_error(response, body) if body.is_a?(Fault)
|
96
118
|
if body.is_a?(Action)
|
97
119
|
return body if body.fault.nil?
|
98
|
-
|
120
|
+
|
121
|
+
connection.raise_error(response, body.fault)
|
99
122
|
end
|
100
|
-
raise Error
|
123
|
+
raise Error, "Expected an action or a fault, but got '#{body.class.name.split('::').last}'"
|
101
124
|
end
|
102
125
|
|
103
|
-
|
126
|
+
#
|
127
|
+
# Returns the connection used by this service.
|
128
|
+
#
|
129
|
+
# This method is intended for internal use by other components of the SDK. Refrain from using it directly, as
|
130
|
+
# backwards compatibility isn't guaranteed.
|
131
|
+
#
|
132
|
+
# @return [Connection] The connection used by this service.
|
133
|
+
#
|
134
|
+
# @api private
|
135
|
+
#
|
136
|
+
def connection
|
137
|
+
return @parent if @parent.is_a? Connection
|
138
|
+
|
139
|
+
@parent.connection
|
140
|
+
end
|
141
|
+
|
142
|
+
#
|
143
|
+
# Returns a string representation of the service.
|
144
|
+
#
|
145
|
+
# @return [String] The string representation.
|
146
|
+
#
|
147
|
+
def inspect
|
148
|
+
"#<#{self.class.name}:#{absolute_path}>"
|
149
|
+
end
|
150
|
+
|
151
|
+
#
|
152
|
+
# Returns a string representation of the service.
|
153
|
+
#
|
154
|
+
# @return [String] The string representation.
|
155
|
+
#
|
156
|
+
def to_s
|
157
|
+
inspect
|
158
|
+
end
|
159
|
+
|
160
|
+
protected
|
161
|
+
|
162
|
+
#
|
163
|
+
# Executes a `get` method.
|
164
|
+
#
|
165
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of arrays containing the names and types of the parameters.
|
166
|
+
# @param opts [Hash] The hash containing the values of the parameters.
|
167
|
+
#
|
168
|
+
# @api private
|
169
|
+
#
|
170
|
+
def internal_get(specs, opts)
|
171
|
+
# Get the values of the built-in options:
|
172
|
+
headers = opts.delete(:headers) || {}
|
173
|
+
query = opts.delete(:query) || {}
|
174
|
+
timeout = opts.delete(:timeout)
|
175
|
+
wait = opts.delete(:wait)
|
176
|
+
wait = true if wait.nil?
|
177
|
+
|
178
|
+
# Get the values of the options specific to this operation:
|
179
|
+
specs.each do |name, kind|
|
180
|
+
value = opts.delete(name)
|
181
|
+
query[name] = Writer.render(value, kind) unless value.nil?
|
182
|
+
end
|
183
|
+
|
184
|
+
# Check the remaining options:
|
185
|
+
check_bad_opts(specs, opts)
|
186
|
+
|
187
|
+
# Create and send the request:
|
188
|
+
request = HttpRequest.new
|
189
|
+
request.method = :GET
|
190
|
+
request.url = absolute_path
|
191
|
+
request.headers = headers
|
192
|
+
request.query = query
|
193
|
+
request.timeout = timeout
|
194
|
+
connection.send(request)
|
195
|
+
result = Future.new(self, request) do |response|
|
196
|
+
raise response if response.is_a?(Exception)
|
197
|
+
|
198
|
+
case response.code
|
199
|
+
when 200
|
200
|
+
internal_read_body(response)
|
201
|
+
else
|
202
|
+
check_fault(response)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
result = result.wait if wait
|
206
|
+
result
|
207
|
+
end
|
208
|
+
|
209
|
+
#
|
210
|
+
# Executes an `add` method.
|
211
|
+
#
|
212
|
+
# @param object [Object] The added object.
|
213
|
+
# @param type [Class] Type type of the added object.
|
214
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of arrays containing the names and types of the parameters.
|
215
|
+
# @param opts [Hash] The hash containing the values of the parameters.
|
216
|
+
#
|
217
|
+
# @api private
|
218
|
+
#
|
219
|
+
def internal_add(object, type, specs, opts)
|
220
|
+
# Get the values of the built-in options:
|
221
|
+
object = type.new(object) if object.is_a?(Hash)
|
222
|
+
headers = opts.delete(:headers) || {}
|
223
|
+
query = opts.delete(:query) || {}
|
224
|
+
timeout = opts.delete(:timeout)
|
225
|
+
wait = opts.delete(:wait)
|
226
|
+
wait = true if wait.nil?
|
227
|
+
|
228
|
+
# Get the values of the options specific to this operation:
|
229
|
+
specs.each do |name, kind|
|
230
|
+
value = opts.delete(name)
|
231
|
+
query[name] = Writer.render(value, kind) unless value.nil?
|
232
|
+
end
|
233
|
+
|
234
|
+
# Check the remaining options:
|
235
|
+
check_bad_opts(specs, opts)
|
236
|
+
|
237
|
+
# Create and send the request:
|
238
|
+
request = HttpRequest.new
|
239
|
+
request.method = :POST
|
240
|
+
request.url = absolute_path
|
241
|
+
request.headers = headers
|
242
|
+
request.query = query
|
243
|
+
request.body = Writer.write(object, indent: true)
|
244
|
+
request.timeout = timeout
|
245
|
+
connection.send(request)
|
246
|
+
result = Future.new(self, request) do |response|
|
247
|
+
raise response if response.is_a?(Exception)
|
248
|
+
|
249
|
+
case response.code
|
250
|
+
when 200, 201, 202
|
251
|
+
internal_read_body(response)
|
252
|
+
else
|
253
|
+
check_fault(response)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
result = result.wait if wait
|
257
|
+
result
|
258
|
+
end
|
259
|
+
|
260
|
+
#
|
261
|
+
# Executes an `update` method.
|
262
|
+
#
|
263
|
+
# @param object [Object] The updated object.
|
264
|
+
# @param type [Class] Type type of the updated object.
|
265
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
|
266
|
+
# @param opts [Hash] The hash containing the values of the parameters.
|
267
|
+
#
|
268
|
+
# @api private
|
269
|
+
#
|
270
|
+
def internal_update(object, type, specs, opts)
|
271
|
+
# get the values of the built-in options:
|
272
|
+
object = type.new(object) if object.is_a?(Hash)
|
273
|
+
headers = opts.delete(:headers) || {}
|
274
|
+
query = opts.delete(:query) || {}
|
275
|
+
timeout = opts.delete(:timeout)
|
276
|
+
wait = opts.delete(:wait)
|
277
|
+
wait = true if wait.nil?
|
278
|
+
|
279
|
+
# Get the values of the options specific to this operation:
|
280
|
+
specs.each do |name, kind|
|
281
|
+
value = opts.delete(name)
|
282
|
+
query[name] = Writer.render(value, kind) unless value.nil?
|
283
|
+
end
|
284
|
+
|
285
|
+
# Check the remaining options:
|
286
|
+
check_bad_opts(specs, opts)
|
287
|
+
|
288
|
+
# Create and send the request:
|
289
|
+
request = HttpRequest.new
|
290
|
+
request.method = :PUT
|
291
|
+
request.url = absolute_path
|
292
|
+
request.headers = headers
|
293
|
+
request.query = query
|
294
|
+
request.body = Writer.write(object, indent: true)
|
295
|
+
request.timeout = timeout
|
296
|
+
connection.send(request)
|
297
|
+
result = Future.new(self, request) do |response|
|
298
|
+
raise response if response.is_a?(Exception)
|
299
|
+
|
300
|
+
case response.code
|
301
|
+
when 200
|
302
|
+
internal_read_body(response)
|
303
|
+
else
|
304
|
+
check_fault(response)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
result = result.wait if wait
|
308
|
+
result
|
309
|
+
end
|
104
310
|
|
311
|
+
#
|
312
|
+
# Executes a `remove` method.
|
313
|
+
#
|
314
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
|
315
|
+
# @param opts [Hash] The hash containing the values of the parameters.
|
316
|
+
#
|
317
|
+
# @api private
|
318
|
+
#
|
319
|
+
def internal_remove(specs, opts)
|
320
|
+
# Get the values of the built-in options:
|
321
|
+
headers = opts.delete(:headers) || {}
|
322
|
+
query = opts.delete(:query) || {}
|
323
|
+
timeout = opts.delete(:timeout)
|
324
|
+
wait = opts.delete(:wait)
|
325
|
+
wait = true if wait.nil?
|
326
|
+
|
327
|
+
# Get the values of the options specific to this operation:
|
328
|
+
specs.each do |name, kind|
|
329
|
+
value = opts.delete(name)
|
330
|
+
query[name] = Writer.render(value, kind) unless value.nil?
|
331
|
+
end
|
332
|
+
|
333
|
+
# Check the remaining options:
|
334
|
+
check_bad_opts(specs, opts)
|
335
|
+
|
336
|
+
# Create and send the request:
|
337
|
+
request = HttpRequest.new
|
338
|
+
request.method = :DELETE
|
339
|
+
request.url = absolute_path
|
340
|
+
request.headers = headers
|
341
|
+
request.query = query
|
342
|
+
request.timeout = timeout
|
343
|
+
connection.send(request)
|
344
|
+
result = Future.new(self, request) do |response|
|
345
|
+
raise response if response.is_a?(Exception)
|
346
|
+
|
347
|
+
check_fault(response) unless response.code == 200
|
348
|
+
end
|
349
|
+
result = result.wait if wait
|
350
|
+
result
|
351
|
+
end
|
352
|
+
|
353
|
+
#
|
354
|
+
# Executes an action method.
|
355
|
+
#
|
356
|
+
# @param name [Symbol] The name of the action, for example `:start`.
|
357
|
+
# @param member [Symbol] The name of the action member that contains the result. For example `:is_attached`. Can
|
358
|
+
# be `nil` if the action doesn't return any value.
|
359
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
|
360
|
+
# @param opts [Hash] The hash containing the parameters of the action.
|
361
|
+
#
|
362
|
+
# @api private
|
363
|
+
#
|
364
|
+
def internal_action(name, member, specs, opts)
|
365
|
+
# Get the values of the built-in options:
|
366
|
+
headers = opts.delete(:headers) || {}
|
367
|
+
query = opts.delete(:query) || {}
|
368
|
+
timeout = opts.delete(:timeout)
|
369
|
+
wait = opts.delete(:wait)
|
370
|
+
wait = true if wait.nil?
|
371
|
+
|
372
|
+
# Create the action:
|
373
|
+
action = Action.new(opts)
|
374
|
+
|
375
|
+
# The constructor of the action doesn't remove the options that it uses, so we need to remove them explicitly
|
376
|
+
# before checking for bad options.
|
377
|
+
specs.each_entry do |key, _|
|
378
|
+
opts.delete(key)
|
379
|
+
end
|
380
|
+
check_bad_opts(specs, opts)
|
381
|
+
|
382
|
+
# Create and send the request:
|
383
|
+
request = HttpRequest.new
|
384
|
+
request.method = :POST
|
385
|
+
request.url = "#{absolute_path}/#{name}"
|
386
|
+
request.headers = headers
|
387
|
+
request.query = query
|
388
|
+
request.body = Writer.write(action, indent: true)
|
389
|
+
request.timeout = timeout
|
390
|
+
connection.send(request)
|
391
|
+
result = Future.new(self, request) do |response|
|
392
|
+
raise response if response.is_a?(Exception)
|
393
|
+
|
394
|
+
case response.code
|
395
|
+
when 200, 201, 202
|
396
|
+
action = check_action(response)
|
397
|
+
action.send(member) if member
|
398
|
+
else
|
399
|
+
check_action(response)
|
400
|
+
end
|
401
|
+
end
|
402
|
+
result = result.wait if wait
|
403
|
+
result
|
404
|
+
end
|
405
|
+
|
406
|
+
#
|
407
|
+
# Checks the content type of the given response, and if it is XML, as expected, reads the body and converts it
|
408
|
+
# to an object. If it isn't XML, then it raises an exception.
|
409
|
+
#
|
410
|
+
# @param response [HttpResponse] The HTTP response to check.
|
411
|
+
# @return [Object] The result of converting the HTTP response body from XML to an SDK object.
|
412
|
+
#
|
413
|
+
# @api private
|
414
|
+
#
|
415
|
+
def internal_read_body(response)
|
416
|
+
# First check if the response body is empty, as it makes no sense to check the content type if there is
|
417
|
+
# no body:
|
418
|
+
connection.raise_error(response, 'The response body is empty') if response.body.nil? || response.body.length.zero?
|
419
|
+
|
420
|
+
# Check the content type, as otherwise the parsing will fail, and the resulting error message won't be explicit
|
421
|
+
# about the cause of the problem:
|
422
|
+
connection.check_xml_content_type(response)
|
423
|
+
|
424
|
+
# Parse the XML and generate the SDK object:
|
425
|
+
Reader.read(response.body)
|
426
|
+
end
|
427
|
+
|
428
|
+
#
|
429
|
+
# Returns the absolute path of this service.
|
430
|
+
#
|
431
|
+
# @return [String] The absolute path of this service. For example, the path of the `vm` service that manages
|
432
|
+
# virtual machine `123` will be `vms/123`. Note that this absolute path doesn't include the `/ovirt-engine/api/'
|
433
|
+
# prefix.
|
434
|
+
#
|
435
|
+
# @api private
|
436
|
+
#
|
437
|
+
def absolute_path
|
438
|
+
return @path if @parent.is_a? Connection
|
439
|
+
|
440
|
+
prefix = @parent.absolute_path
|
441
|
+
return @path if prefix.empty?
|
442
|
+
|
443
|
+
"#{prefix}/#{@path}"
|
444
|
+
end
|
445
|
+
|
446
|
+
private
|
447
|
+
|
448
|
+
#
|
449
|
+
# Checks if the given hash contains any value, and if it does raises an exception indicating that they are not
|
450
|
+
# supported.
|
451
|
+
#
|
452
|
+
# @param specs [Array<Array<Symbol, Class>>] An array of tuples containing the names and types of the parameters.
|
453
|
+
# @param opts [Hash] The hash containing the values of the parameters.
|
454
|
+
#
|
455
|
+
def check_bad_opts(specs, opts)
|
456
|
+
return if opts.empty?
|
457
|
+
|
458
|
+
bad_names = opts.keys
|
459
|
+
bad_text = nice_list(bad_names)
|
460
|
+
if bad_names.length > 1
|
461
|
+
message = "The options #{bad_text} aren't supported."
|
462
|
+
else
|
463
|
+
message = "The option #{bad_text} isn't supported."
|
464
|
+
end
|
465
|
+
good_names = specs.map(&:first)
|
466
|
+
unless good_names.empty?
|
467
|
+
good_text = nice_list(good_names)
|
468
|
+
if good_names.length > 1
|
469
|
+
message << " The supported options are #{good_text}."
|
470
|
+
else
|
471
|
+
message << " The only supported option is #{good_text}."
|
472
|
+
end
|
473
|
+
end
|
474
|
+
raise Error, message
|
475
|
+
end
|
476
|
+
|
477
|
+
#
|
478
|
+
# Generates a human readable list containing the names of the given symbols.
|
479
|
+
#
|
480
|
+
# @param items [Array<Symbol>]
|
481
|
+
# @return [String] An string containing the names of the symbols, sorted, quoted, and in a gramatically correct
|
482
|
+
# format.
|
483
|
+
#
|
484
|
+
def nice_list(items)
|
485
|
+
return nil if items.empty?
|
486
|
+
|
487
|
+
items = items.sort
|
488
|
+
items = items.map { |item| "'#{item}'" }
|
489
|
+
return items.first if items.length == 1
|
490
|
+
|
491
|
+
head = items[0, items.length - 1].join(', ')
|
492
|
+
tail = items.last
|
493
|
+
"#{head} and #{tail}"
|
494
|
+
end
|
495
|
+
end
|
105
496
|
end
|