hyperkit 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +23 -0
- data/Guardfile +43 -0
- data/LICENSE.txt +47 -0
- data/README.md +341 -0
- data/Rakefile +6 -0
- data/Vagrantfile +123 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/hyperkit.gemspec +33 -0
- data/lib/hyperkit.rb +58 -0
- data/lib/hyperkit/client.rb +82 -0
- data/lib/hyperkit/client/certificates.rb +102 -0
- data/lib/hyperkit/client/containers.rb +1100 -0
- data/lib/hyperkit/client/images.rb +672 -0
- data/lib/hyperkit/client/networks.rb +47 -0
- data/lib/hyperkit/client/operations.rb +123 -0
- data/lib/hyperkit/client/profiles.rb +59 -0
- data/lib/hyperkit/configurable.rb +110 -0
- data/lib/hyperkit/connection.rb +196 -0
- data/lib/hyperkit/default.rb +140 -0
- data/lib/hyperkit/error.rb +267 -0
- data/lib/hyperkit/middleware/follow_redirects.rb +131 -0
- data/lib/hyperkit/response/raise_error.rb +47 -0
- data/lib/hyperkit/version.rb +3 -0
- metadata +116 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
module Hyperkit
|
2
|
+
|
3
|
+
class Client
|
4
|
+
|
5
|
+
# Methods for the networks API
|
6
|
+
#
|
7
|
+
# @see Hyperkit::Client
|
8
|
+
# @see https://github.com/lxc/lxd/blob/master/specs/rest-api.md
|
9
|
+
module Networks
|
10
|
+
|
11
|
+
# List of networks defined on the host
|
12
|
+
#
|
13
|
+
# @return [Array<String>] An array of networks defined on the host
|
14
|
+
#
|
15
|
+
# @example Get list of networks
|
16
|
+
# Hyperkit.networks #=> ["lo", "eth0", "lxcbr0"]
|
17
|
+
def networks
|
18
|
+
response = get(networks_path)
|
19
|
+
response.metadata.map { |path| path.split('/').last }
|
20
|
+
end
|
21
|
+
|
22
|
+
# Get information on a network
|
23
|
+
#
|
24
|
+
# @return [Sawyer::Resource] Network information
|
25
|
+
#
|
26
|
+
# @example Get information about lxcbr0
|
27
|
+
# Hyperkit.network("lxcbr0") #=> {:name=>"lxcbr0", :type=>"bridge", :used_by=>[]}
|
28
|
+
def network(name)
|
29
|
+
get(network_path(name)).metadata
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def network_path(name)
|
35
|
+
File.join(networks_path, name)
|
36
|
+
end
|
37
|
+
|
38
|
+
def networks_path
|
39
|
+
"/1.0/networks"
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'active_support/core_ext/hash/except'
|
2
|
+
|
3
|
+
module Hyperkit
|
4
|
+
|
5
|
+
class Client
|
6
|
+
|
7
|
+
# Methods for the operations API
|
8
|
+
#
|
9
|
+
# @see Hyperkit::Client
|
10
|
+
# @see https://github.com/lxc/lxd/blob/master/specs/rest-api.md
|
11
|
+
module Operations
|
12
|
+
|
13
|
+
# List of operations active on the server
|
14
|
+
#
|
15
|
+
# This will include operations that are currently executing, as well as
|
16
|
+
# operations that are paused until {#wait_for_operation} is called, at
|
17
|
+
# which time they will begin executing.
|
18
|
+
#
|
19
|
+
# Additionally, since LXD keeps completed operations around for 5 seconds,
|
20
|
+
# the list returned may include recently completed operations.
|
21
|
+
#
|
22
|
+
# @return [Array<String>] An array of UUIDs identifying waiting, active, and recently completed (<5 seconds) operations
|
23
|
+
#
|
24
|
+
# @example Get list of operations
|
25
|
+
# Hyperkit.operations #=> ["931e27fb-2057-4cbe-a49d-fd114713fa74"]
|
26
|
+
def operations
|
27
|
+
response = get(operations_path)
|
28
|
+
response.metadata.to_h.values.flatten.map { |path| path.split('/').last }
|
29
|
+
end
|
30
|
+
|
31
|
+
# Retrieve information about an operation
|
32
|
+
#
|
33
|
+
# @param [String] uuid UUID of the operation
|
34
|
+
# @return [Sawyer::Resource] Operation information
|
35
|
+
#
|
36
|
+
# @example Retrieve information about an operation
|
37
|
+
# Hyperkit.operation("d5f359ae-ddcb-4f09-a8f8-0cc2f3c8b0df") #=> {
|
38
|
+
# :id => "d5f359ae-ddcb-4f09-a8f8-0cc2f3c8b0df",
|
39
|
+
# :class => "task",
|
40
|
+
# :created_at => 2016-04-14 21:30:59 UTC,
|
41
|
+
# :updated_at => 2016-04-14 21:30:59 UTC,
|
42
|
+
# :status => "Running",
|
43
|
+
# :status_code => 103,
|
44
|
+
# :resources => {
|
45
|
+
# :containers => ["/1.0/containers/test-container"]
|
46
|
+
# },
|
47
|
+
# :metadata => nil,
|
48
|
+
# :may_cancel => false,
|
49
|
+
# :err => ""
|
50
|
+
# }
|
51
|
+
def operation(uuid)
|
52
|
+
get(operation_path(uuid)).metadata
|
53
|
+
end
|
54
|
+
|
55
|
+
# Cancel a running operation
|
56
|
+
#
|
57
|
+
# Calling this will change the state of the operation to
|
58
|
+
# <code>cancelling</code>. Note that the operation must be cancelable,
|
59
|
+
# which can be ascertained by calling {#operation} and checking the
|
60
|
+
# <code>may_cancel</code> property.
|
61
|
+
#
|
62
|
+
# @param [String] uuid UUID of the operation
|
63
|
+
# @return [Sawyer::Resource]
|
64
|
+
#
|
65
|
+
# @example Cancel an operation
|
66
|
+
# Hyperkit.cancel_operation("8b3dd0c2-9dad-4964-b00d-e21481a47fb8") => {}
|
67
|
+
def cancel_operation(uuid)
|
68
|
+
delete(operation_path(uuid)).metadata
|
69
|
+
end
|
70
|
+
|
71
|
+
# Wait for an asynchronous operation to complete
|
72
|
+
#
|
73
|
+
# Note that this is only needed if {#Hyperkit::auto_sync} has been
|
74
|
+
# set to <code>false</code>, or if the option <code>sync: false</code>
|
75
|
+
# has been passed to an asynchronous method.
|
76
|
+
#
|
77
|
+
# @param [String] uuid UUID of the operation
|
78
|
+
# @param [Fixnum] timeout Maximum time to wait (default: indefinite)
|
79
|
+
# @return [Sawyer::Resource] Operation result
|
80
|
+
#
|
81
|
+
# @example Wait for the creation of a container
|
82
|
+
# Hyperkit.auto_sync = false
|
83
|
+
# op = Hyperkit.create_container("test-container", alias: "ubuntu/amd64/default")
|
84
|
+
# Hyperkit.wait_for_operation(op.id)
|
85
|
+
#
|
86
|
+
# @example Wait, but time out if the operation is not complete after 30 seconds
|
87
|
+
# op = Hyperkit.copy_container("test1", "test2", sync: false)
|
88
|
+
# Hyperkit.wait_for_operation(op.id, timeout: 30)
|
89
|
+
def wait_for_operation(uuid, timeout=nil)
|
90
|
+
url = File.join(operation_path(uuid), "wait")
|
91
|
+
url += "?timeout=#{timeout}" if timeout.to_i > 0
|
92
|
+
|
93
|
+
get(url).metadata
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def handle_async(response, sync)
|
99
|
+
|
100
|
+
sync = sync.nil? ? auto_sync : sync
|
101
|
+
|
102
|
+
if sync
|
103
|
+
wait_for_operation(response.id)
|
104
|
+
else
|
105
|
+
response
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
def operation_path(uuid)
|
111
|
+
File.join(operations_path, uuid)
|
112
|
+
end
|
113
|
+
|
114
|
+
def operations_path
|
115
|
+
"/1.0/operations"
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
123
|
+
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'active_support/core_ext/hash/except'
|
2
|
+
|
3
|
+
module Hyperkit
|
4
|
+
|
5
|
+
class Client
|
6
|
+
|
7
|
+
# Methods for the profiles API
|
8
|
+
#
|
9
|
+
# @see Hyperkit::Client
|
10
|
+
# @see https://github.com/lxc/lxd/blob/master/specs/rest-api.md
|
11
|
+
module Profiles
|
12
|
+
|
13
|
+
# GET /profiles
|
14
|
+
def profiles
|
15
|
+
response = get(profiles_path)
|
16
|
+
response.metadata.map { |path| path.split('/').last }
|
17
|
+
end
|
18
|
+
|
19
|
+
# POST /profiles
|
20
|
+
def create_profile(name, options={})
|
21
|
+
options = options.merge(name: name)
|
22
|
+
post(profiles_path, options).metadata
|
23
|
+
end
|
24
|
+
|
25
|
+
# GET /profiles/<name>
|
26
|
+
def profile(name)
|
27
|
+
get(profile_path(name)).metadata
|
28
|
+
end
|
29
|
+
|
30
|
+
# PUT /profiles/<name>
|
31
|
+
def update_profile(name, options={})
|
32
|
+
put(profile_path(name), options.except(:name)).metadata
|
33
|
+
end
|
34
|
+
|
35
|
+
# POST /profiles/<name>
|
36
|
+
def rename_profile(old_name, new_name)
|
37
|
+
post(profile_path(old_name), { name: new_name }).metadata
|
38
|
+
end
|
39
|
+
|
40
|
+
# DELETE /profiles/<name>
|
41
|
+
def delete_profile(name)
|
42
|
+
delete(profile_path(name)).metadata
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def profiles_path
|
48
|
+
"/1.0/profiles"
|
49
|
+
end
|
50
|
+
|
51
|
+
def profile_path(name)
|
52
|
+
File.join(profiles_path, name)
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
################################################################################
|
2
|
+
# #
|
3
|
+
# Modeled on Octokit::Configurable #
|
4
|
+
# #
|
5
|
+
# Original Octokit license #
|
6
|
+
# ---------------------------------------------------------------------------- #
|
7
|
+
# Copyright (c) 2009-2016 Wynn Netherland, Adam Stacoviak, Erik Michaels-Ober #
|
8
|
+
# #
|
9
|
+
# Permission is hereby granted, free of charge, to any person obtaining a #
|
10
|
+
# copy of this software and associated documentation files (the "Software"), #
|
11
|
+
# to deal in the Software without restriction, including without limitation #
|
12
|
+
# the rights to use, copy, modify, merge, publish, distribute, sublicense, #
|
13
|
+
# and/or sell copies of the Software, and to permit persons to whom the #
|
14
|
+
# Software is furnished to do so, subject to the following conditions: #
|
15
|
+
# #
|
16
|
+
# The above copyright notice and this permission notice shall be included #
|
17
|
+
# in all copies or substantial portions of the Software. #
|
18
|
+
# ---------------------------------------------------------------------------- #
|
19
|
+
# #
|
20
|
+
################################################################################
|
21
|
+
|
22
|
+
|
23
|
+
module Hyperkit
|
24
|
+
|
25
|
+
# Configuration options for {Client}, defaulting to values
|
26
|
+
# in {Default}
|
27
|
+
module Configurable
|
28
|
+
|
29
|
+
# @!attribute api_endpoint
|
30
|
+
# @return [String] the base URL for API requests (default: <code>https://localhost:8443/</code>)
|
31
|
+
# @!attribute auto_sync
|
32
|
+
# @return [String] whether to automatically wait on asynchronous events (default: <code>true</code>)
|
33
|
+
# @!attribute client_cert
|
34
|
+
# @return [String] the client certificate used to authenticate to the LXD server
|
35
|
+
# @!attribute client_key
|
36
|
+
# @return [String] the client key used to authenticate to the LXD server
|
37
|
+
# @!attribute default_media_type
|
38
|
+
# @return [String] the preferred media type (for API versioning, for example)
|
39
|
+
# @!attribute middleware
|
40
|
+
# @see https://github.com/lostisland/faraday
|
41
|
+
# @return [Faraday::Builder or Faraday::RackBuilder] middleware for Faraday
|
42
|
+
# @!attribute proxy
|
43
|
+
# @see https://github.com/lostisland/faraday
|
44
|
+
# @return [String] the URI of a proxy server used to connect to the LXD server
|
45
|
+
# @!attribute user_agent
|
46
|
+
# @return [String] the <code>User-Agent</code> header used for requests made to the LXD server
|
47
|
+
# @!attribute verify_ssl
|
48
|
+
# @return [Boolean] whether or not to verify the LXD server's SSL certificate
|
49
|
+
|
50
|
+
attr_accessor :auto_sync, :client_cert, :client_key, :default_media_type,
|
51
|
+
:middleware, :proxy, :user_agent, :verify_ssl
|
52
|
+
|
53
|
+
attr_writer :api_endpoint
|
54
|
+
|
55
|
+
class << self
|
56
|
+
|
57
|
+
# List of configurable keys for {Hyperkit::Client}
|
58
|
+
# @return [Array] of option keys
|
59
|
+
def keys
|
60
|
+
@keys ||= [
|
61
|
+
:api_endpoint,
|
62
|
+
:auto_sync,
|
63
|
+
:client_cert,
|
64
|
+
:client_key,
|
65
|
+
:default_media_type,
|
66
|
+
:middleware,
|
67
|
+
:proxy,
|
68
|
+
:user_agent,
|
69
|
+
:verify_ssl
|
70
|
+
]
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
# Set configuration options using a block
|
76
|
+
def configure
|
77
|
+
yield self
|
78
|
+
end
|
79
|
+
|
80
|
+
# Reset configuration options to default values
|
81
|
+
def reset!
|
82
|
+
Hyperkit::Configurable.keys.each do |key|
|
83
|
+
instance_variable_set(:"@#{key}", Hyperkit::Default.options[key])
|
84
|
+
end
|
85
|
+
self
|
86
|
+
end
|
87
|
+
|
88
|
+
alias setup reset!
|
89
|
+
|
90
|
+
# Compares client options to a Hash of requested options
|
91
|
+
#
|
92
|
+
# @param opts [Hash] Options to compare with current client options
|
93
|
+
# @return [Boolean]
|
94
|
+
def same_options?(opts)
|
95
|
+
opts.hash == options.hash
|
96
|
+
end
|
97
|
+
|
98
|
+
def api_endpoint
|
99
|
+
File.join(@api_endpoint, "")
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def options
|
105
|
+
Hash[Hyperkit::Configurable.keys.map{|key| [key, instance_variable_get(:"@#{key}")]}]
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
################################################################################
|
2
|
+
# #
|
3
|
+
# Based on Octokit::Connection #
|
4
|
+
# #
|
5
|
+
# Original Octokit license #
|
6
|
+
# ---------------------------------------------------------------------------- #
|
7
|
+
# Copyright (c) 2009-2016 Wynn Netherland, Adam Stacoviak, Erik Michaels-Ober #
|
8
|
+
# #
|
9
|
+
# Permission is hereby granted, free of charge, to any person obtaining a #
|
10
|
+
# copy of this software and associated documentation files (the "Software"), #
|
11
|
+
# to deal in the Software without restriction, including without limitation #
|
12
|
+
# the rights to use, copy, modify, merge, publish, distribute, sublicense, #
|
13
|
+
# and/or sell copies of the Software, and to permit persons to whom the #
|
14
|
+
# Software is furnished to do so, subject to the following conditions: #
|
15
|
+
# #
|
16
|
+
# The above copyright notice and this permission notice shall be included #
|
17
|
+
# in all copies or substantial portions of the Software. #
|
18
|
+
# ---------------------------------------------------------------------------- #
|
19
|
+
# #
|
20
|
+
################################################################################
|
21
|
+
|
22
|
+
require 'sawyer'
|
23
|
+
|
24
|
+
module Hyperkit
|
25
|
+
|
26
|
+
# Network layer for API clients.
|
27
|
+
module Connection
|
28
|
+
|
29
|
+
# Header keys that can be passed in options hash to {#get},{#head}
|
30
|
+
CONVENIENCE_HEADERS = Set.new([:accept, :content_type])
|
31
|
+
|
32
|
+
# Make a HTTP GET request
|
33
|
+
#
|
34
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
35
|
+
# @param options [Hash] Query and header params for request
|
36
|
+
# @return [Sawyer::Resource]
|
37
|
+
def get(url, options = {})
|
38
|
+
request :get, url, parse_query_and_convenience_headers(options)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Make a HTTP POST request
|
42
|
+
#
|
43
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
44
|
+
# @param options [Hash] Body and header params for request
|
45
|
+
# @return [Sawyer::Resource]
|
46
|
+
def post(url, options = {})
|
47
|
+
request :post, url, options
|
48
|
+
end
|
49
|
+
|
50
|
+
# Make a HTTP PUT request
|
51
|
+
#
|
52
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
53
|
+
# @param options [Hash] Body and header params for request
|
54
|
+
# @return [Sawyer::Resource]
|
55
|
+
def put(url, options = {})
|
56
|
+
request :put, url, options
|
57
|
+
end
|
58
|
+
|
59
|
+
# Make a HTTP PATCH request
|
60
|
+
#
|
61
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
62
|
+
# @param options [Hash] Body and header params for request
|
63
|
+
# @return [Sawyer::Resource]
|
64
|
+
def patch(url, options = {})
|
65
|
+
request :patch, url, options
|
66
|
+
end
|
67
|
+
|
68
|
+
# Make a HTTP DELETE request
|
69
|
+
#
|
70
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
71
|
+
# @param options [Hash] Query and header params for request
|
72
|
+
# @return [Sawyer::Resource]
|
73
|
+
def delete(url, options = {})
|
74
|
+
request :delete, url, options
|
75
|
+
end
|
76
|
+
|
77
|
+
# Make a HTTP HEAD request
|
78
|
+
#
|
79
|
+
# @param url [String] The path, relative to {#api_endpoint}
|
80
|
+
# @param options [Hash] Query and header params for request
|
81
|
+
# @return [Sawyer::Resource]
|
82
|
+
def head(url, options = {})
|
83
|
+
request :head, url, parse_query_and_convenience_headers(options)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Hypermedia agent for the GitHub API
|
87
|
+
#
|
88
|
+
# @return [Sawyer::Agent]
|
89
|
+
def agent
|
90
|
+
@agent ||= Sawyer::Agent.new(endpoint, sawyer_options) do |http|
|
91
|
+
http.headers[:accept] = default_media_type
|
92
|
+
http.headers[:content_type] = "application/json"
|
93
|
+
http.headers[:user_agent] = user_agent
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Fetch the root resource for the API
|
98
|
+
#
|
99
|
+
# @return [Sawyer::Resource]
|
100
|
+
def root
|
101
|
+
get "/"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Response for last HTTP request
|
105
|
+
#
|
106
|
+
# @return [Sawyer::Response]
|
107
|
+
def last_response
|
108
|
+
@last_response if defined? @last_response
|
109
|
+
end
|
110
|
+
|
111
|
+
protected
|
112
|
+
|
113
|
+
def endpoint
|
114
|
+
api_endpoint
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def reset_agent
|
120
|
+
@agent = nil
|
121
|
+
end
|
122
|
+
|
123
|
+
def request(method, path, data, options = {})
|
124
|
+
if data.is_a?(Hash)
|
125
|
+
options[:query] = data.delete(:query) || {}
|
126
|
+
options[:headers] = data.delete(:headers) || {}
|
127
|
+
url_encode = data.delete(:url_encode) || true
|
128
|
+
|
129
|
+
if accept = data.delete(:accept)
|
130
|
+
options[:headers][:accept] = accept
|
131
|
+
end
|
132
|
+
if data[:raw_body]
|
133
|
+
data = data[:raw_body]
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
path = URI::Parser.new.escape(path.to_s) if url_encode
|
138
|
+
|
139
|
+
@last_response = response = agent.call(method, path, data, options)
|
140
|
+
response.data
|
141
|
+
end
|
142
|
+
|
143
|
+
# Executes the request, checking if it was successful
|
144
|
+
#
|
145
|
+
# @return [Boolean] True on success, false otherwise
|
146
|
+
def boolean_from_response(method, path, options = {})
|
147
|
+
request(method, path, options)
|
148
|
+
@last_response.status == 204
|
149
|
+
rescue Hyperkit::NotFound
|
150
|
+
false
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
def sawyer_options
|
155
|
+
opts = {
|
156
|
+
:links_parser => Sawyer::LinkParsers::Simple.new,
|
157
|
+
}
|
158
|
+
conn_opts = {}
|
159
|
+
conn_opts[:builder] = @middleware if @middleware
|
160
|
+
conn_opts[:proxy] = @proxy if @proxy
|
161
|
+
|
162
|
+
conn_opts[:ssl] = {
|
163
|
+
verify: verify_ssl
|
164
|
+
}
|
165
|
+
|
166
|
+
if client_cert && File.exist?(client_cert)
|
167
|
+
conn_opts[:ssl][:client_cert] = OpenSSL::X509::Certificate.new(File.read(client_cert))
|
168
|
+
end
|
169
|
+
|
170
|
+
if client_key && File.exist?(client_key)
|
171
|
+
conn_opts[:ssl][:client_key] = OpenSSL::PKey::RSA.new(File.read(client_key))
|
172
|
+
end
|
173
|
+
|
174
|
+
opts[:faraday] = Faraday.new(conn_opts)
|
175
|
+
|
176
|
+
opts
|
177
|
+
end
|
178
|
+
|
179
|
+
def parse_query_and_convenience_headers(options)
|
180
|
+
headers = options.delete(:headers) { Hash.new }
|
181
|
+
CONVENIENCE_HEADERS.each do |h|
|
182
|
+
if header = options.delete(h)
|
183
|
+
headers[h] = header
|
184
|
+
end
|
185
|
+
end
|
186
|
+
query = options.delete(:query)
|
187
|
+
opts = {:query => options}
|
188
|
+
opts[:query].merge!(query) if query && query.is_a?(Hash)
|
189
|
+
opts[:headers] = headers unless headers.empty?
|
190
|
+
|
191
|
+
opts
|
192
|
+
end
|
193
|
+
|
194
|
+
end
|
195
|
+
|
196
|
+
end
|