diff --git a/README.md b/README.md index c01f2fa2..72d44ece 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,20 @@ We try to support the last 3 minor versions, matching the [official support poli Kubernetes 1.2 and below have known issues and are unsupported. Kubernetes 1.3 presumed to still work although nobody is really testing on such old versions... +### Changing the underlying HTTP library + +Kubeclient uses [rest_client](https://github.com/rest-client/rest-client) by default to perform HTTP requests. You can change this by specifying the `http_client_type` option when initializing `Kubeclient::Client`. Currently the only other option is [httpclient](https://github.com/nahi/httpclient). + +To use a different HTTP library, specify the `http_client_type` option when initializing `Kubeclient::Client`: + +```ruby +client = Kubeclient::Client.new( + 'https://localhost:8443/api/', "v1", http_client_type: 'httpclient' +) +``` + +Using a different underlying HTTP library might have various advantages. For example, the `httpclient` gem is able to re-use HTTP connections between requests, as opposed to `rest_client`, which closes the HTTP connection after each request (see ). + ## Supported actions & examples: Summary of main CRUD actions: @@ -548,7 +562,7 @@ Other formats are: - `:ros` (default) for `RecursiveOpenStruct` - `:parsed` for `JSON.parse` - `:parsed_symbolized` for `JSON.parse(..., symbolize_names: true)` - - a class of your choice (this will instantiate a new instance of that class with the raw value of the response body) + - a class of your choice (this will instantiate a new instance of that class with the raw value of the response body) ### Watch — Receive entities updates diff --git a/kubeclient.gemspec b/kubeclient.gemspec index f866155f..7f6fb980 100644 --- a/kubeclient.gemspec +++ b/kubeclient.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'googleauth', '~> 0.5.1' spec.add_development_dependency('mocha', '~> 1.5') spec.add_development_dependency 'openid_connect', '~> 1.1' + spec.add_development_dependency 'httpclient', '~> 2.0' spec.add_dependency 'jsonpath', '~> 1.0' spec.add_dependency 'rest-client', '~> 2.0' diff --git a/lib/kubeclient.rb b/lib/kubeclient.rb index eed48728..743a0460 100644 --- a/lib/kubeclient.rb +++ b/lib/kubeclient.rb @@ -8,10 +8,12 @@ require 'kubeclient/exec_credentials' require 'kubeclient/gcp_auth_provider' require 'kubeclient/http_error' +require 'kubeclient/httpclient_wrapper' require 'kubeclient/missing_kind_compatibility' require 'kubeclient/oidc_auth_provider' require 'kubeclient/resource' require 'kubeclient/resource_not_found_error' +require 'kubeclient/rest_client_wrapper' require 'kubeclient/version' require 'kubeclient/watch_stream' diff --git a/lib/kubeclient/common.rb b/lib/kubeclient/common.rb index 0f3dde5a..e11c916f 100644 --- a/lib/kubeclient/common.rb +++ b/lib/kubeclient/common.rb @@ -36,6 +36,8 @@ module ClientMixin DEFAULT_HTTP_PROXY_URI = nil DEFAULT_HTTP_MAX_REDIRECTS = 10 + DEFAULT_HTTP_CLIENT_TYPE = 'rest_client'.freeze + SEARCH_ARGUMENTS = { 'labelSelector' => :label_selector, 'fieldSelector' => :field_selector, @@ -68,7 +70,8 @@ def initialize_client( timeouts: DEFAULT_TIMEOUTS, http_proxy_uri: DEFAULT_HTTP_PROXY_URI, http_max_redirects: DEFAULT_HTTP_MAX_REDIRECTS, - as: :ros + as: :ros, + http_client_type: DEFAULT_HTTP_CLIENT_TYPE ) validate_auth_options(auth_options) handle_uri(uri, path) @@ -86,6 +89,7 @@ def initialize_client( @http_proxy_uri = http_proxy_uri ? http_proxy_uri.to_s : nil @http_max_redirects = http_max_redirects @as = as + @http_client_type = http_client_type if auth_options[:bearer_token] bearer_token(@auth_options[:bearer_token]) @@ -282,6 +286,7 @@ def self.underscore_entity(entity_name) .downcase end + # *DEPRECATED:* Use +create_http_client+ instead. def create_rest_client(path = nil) path ||= @api_endpoint.path options = { @@ -300,9 +305,44 @@ def create_rest_client(path = nil) RestClient::Resource.new(@api_endpoint.merge(path).to_s, options) end + # *DEPRECATED:* Use +http_client+ instead. def rest_client - @rest_client ||= begin - create_rest_client("#{@api_endpoint.path}/#{@api_version}") + http_client + end + + # Returns the instance of the HTTP library used for making HTTP requests. + # By default this will be an instance of RestClient from `rest_client` gem. + # You can select a different HTTP library when creating and instance of +Kubeclient::Client+, + # see the +http_client_type+ option on +Kubeclient::Client.new+. + def http_client + http.client + end + + # Creates and returns a new instance of a class that implements the interface + # defined by +HTTPWrapper+ class. + def create_http_client(url = nil, options = nil, http_client_type = nil) + url = "#{@api_endpoint}/#{@api_version}" if url.nil? + if options.nil? + options = { + ssl_ca_file: @ssl_options[:ca_file], + ssl_cert_store: @ssl_options[:cert_store], + verify_ssl: @ssl_options[:verify_ssl], + ssl_client_cert: @ssl_options[:client_cert], + ssl_client_key: @ssl_options[:client_key], + proxy: @http_proxy_uri, + max_redirects: @http_max_redirects, + user: @auth_options[:username], + password: @auth_options[:password], + open_timeout: @timeouts[:open], + read_timeout: @timeouts[:read] + } + end + http_client_type = @http_client_type if http_client_type.nil? + + if http_client_type == 'httpclient' + HTTPClientWrapper.new(url, options) + else + RestClientWrapper.new(url, options) end end @@ -352,8 +392,7 @@ def get_entities(entity_type, resource_name, options = {}) ns_prefix = build_namespace_prefix(options[:namespace]) response = handle_exception do - rest_client[ns_prefix + resource_name] - .get({ 'params' => params }.merge(@headers)) + http.get(ns_prefix + resource_name, params: params, headers: @headers) end format_response(options[:as] || @as, response.body, entity_type) end @@ -365,8 +404,7 @@ def get_entities(entity_type, resource_name, options = {}) def get_entity(resource_name, name, namespace = nil, options = {}) ns_prefix = build_namespace_prefix(namespace) response = handle_exception do - rest_client[ns_prefix + resource_name + "/#{name}"] - .get(@headers) + http.get(ns_prefix + resource_name + "/#{name}", headers: @headers) end format_response(options[:as] || @as, response.body) end @@ -377,14 +415,10 @@ def delete_entity(resource_name, name, namespace = nil, delete_options: {}) ns_prefix = build_namespace_prefix(namespace) payload = delete_options_hash.to_json unless delete_options_hash.empty? response = handle_exception do - rs = rest_client[ns_prefix + resource_name + "/#{name}"] - RestClient::Request.execute( - rs.options.merge( - method: :delete, - url: rs.url, - headers: { 'Content-Type' => 'application/json' }.merge(@headers), - payload: payload - ) + http.delete( + ns_prefix + resource_name + "/#{name}", + body: payload, + headers: { 'Content-Type' => 'application/json' }.merge(@headers) ) end format_response(@as, response.body) @@ -403,8 +437,11 @@ def create_entity(entity_type, resource_name, entity_config) hash[:kind] = entity_type hash[:apiVersion] = @api_group + @api_version response = handle_exception do - rest_client[ns_prefix + resource_name] - .post(hash.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + http.post( + ns_prefix + resource_name, + body: hash.to_json, + headers: { 'Content-Type' => 'application/json' }.merge(@headers) + ) end format_response(@as, response.body) end @@ -413,8 +450,11 @@ def update_entity(resource_name, entity_config) name = entity_config[:metadata][:name] ns_prefix = build_namespace_prefix(entity_config[:metadata][:namespace]) response = handle_exception do - rest_client[ns_prefix + resource_name + "/#{name}"] - .put(entity_config.to_h.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + http.put( + ns_prefix + resource_name + "/#{name}", + body: entity_config.to_h.to_json, + headers: { 'Content-Type' => 'application/json' }.merge(@headers) + ) end format_response(@as, response.body) end @@ -422,11 +462,11 @@ def update_entity(resource_name, entity_config) def patch_entity(resource_name, name, patch, strategy, namespace) ns_prefix = build_namespace_prefix(namespace) response = handle_exception do - rest_client[ns_prefix + resource_name + "/#{name}"] - .patch( - patch.to_json, - { 'Content-Type' => "application/#{strategy}+json" }.merge(@headers) - ) + http.patch( + ns_prefix + resource_name + "/#{name}", + body: patch.to_json, + headers: { 'Content-Type' => "application/#{strategy}+json" }.merge(@headers) + ) end format_response(@as, response.body) end @@ -435,11 +475,11 @@ def apply_entity(resource_name, resource, field_manager:, force: true) name = "#{resource[:metadata][:name]}?fieldManager=#{field_manager}&force=#{force}" ns_prefix = build_namespace_prefix(resource[:metadata][:namespace]) response = handle_exception do - rest_client[ns_prefix + resource_name + "/#{name}"] - .patch( - resource.to_json, - { 'Content-Type' => 'application/apply-patch+yaml' }.merge(@headers) - ) + http.patch( + ns_prefix + resource_name + "/#{name}", + body: resource.to_json, + headers: { 'Content-Type' => 'application/apply-patch+yaml' }.merge(@headers) + ) end format_response(@as, response.body) end @@ -471,8 +511,11 @@ def get_pod_log(pod_name, namespace, ns = build_namespace_prefix(namespace) handle_exception do - rest_client[ns + "pods/#{pod_name}/log"] - .get({ 'params' => params }.merge(@headers)) + http.get( + ns + "pods/#{pod_name}/log", + params: params, + headers: @headers + ) end end @@ -503,14 +546,17 @@ def proxy_url(kind, name, port, namespace = '') @entities[kind.to_s].resource_name end ns_prefix = build_namespace_prefix(namespace) - rest_client["#{ns_prefix}#{entity_name_plural}/#{name}:#{port}/proxy"].url + "#{@api_endpoint}/#{@api_version}/#{ns_prefix}#{entity_name_plural}/#{name}:#{port}/proxy" end def process_template(template) ns_prefix = build_namespace_prefix(template[:metadata][:namespace]) response = handle_exception do - rest_client[ns_prefix + 'processedtemplates'] - .post(template.to_h.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + http.post( + ns_prefix + 'processedtemplates', + body: template.to_h.to_json, + headers: { 'Content-Type' => 'application/json' }.merge(@headers) + ) end JSON.parse(response) end @@ -523,12 +569,19 @@ def api_valid? end def api - response = handle_exception { create_rest_client.get(@headers) } + response = handle_exception { create_http_client(@api_endpoint.to_s).get(headers: @headers) } JSON.parse(response) end private + # Returns an object that implements the interface defined by the +HTTPWrapper+ class. + def http + @http ||= begin + create_http_client + end + end + IRREGULAR_NAMES = { # In a few cases, the given kind itself is still plural. # https://github.com/kubernetes/kubernetes/issues/8115 @@ -599,7 +652,7 @@ def load_entities end def fetch_entities - JSON.parse(handle_exception { rest_client.get(@headers) }) + JSON.parse(handle_exception { http.get(headers: @headers) }) end def bearer_token(bearer_token) diff --git a/lib/kubeclient/http_wrapper.rb b/lib/kubeclient/http_wrapper.rb new file mode 100644 index 00000000..760118e8 --- /dev/null +++ b/lib/kubeclient/http_wrapper.rb @@ -0,0 +1,30 @@ +module Kubeclient + # Defines the common API for libraries to be used by Kubeclient for making HTTP requests. + # To create a wrapper for a library, create a new class that inherits from this class + # and override the +request+ method. See +RestClientWrapper+ or +HTTPClientWrapper+ for examples. + class HTTPWrapper + def delete(path = nil, **options) + request(:delete, path, **options) + end + + def get(path = nil, **options) + request(:get, path, **options) + end + + def patch(path = nil, **options) + request(:patch, path, **options) + end + + def post(path = nil, **options) + request(:post, path, **options) + end + + def put(path = nil, **options) + request(:put, path, **options) + end + + def request + raise 'Must implement the `request` method.' + end + end +end diff --git a/lib/kubeclient/httpclient_wrapper.rb b/lib/kubeclient/httpclient_wrapper.rb new file mode 100644 index 00000000..ef31c79f --- /dev/null +++ b/lib/kubeclient/httpclient_wrapper.rb @@ -0,0 +1,23 @@ +require_relative 'http_wrapper' + +module Kubeclient + # Wraps the API of +httpclient+ gem to be used by Kubeclient for making HTTP requests. + class HTTPClientWrapper < HTTPWrapper + def initialize(url, options) + @url = url + @options = options + @client = HTTPClient.new + end + + attr_reader :client + + def request(method, path = nil, **options) + uri = [@url, path].compact.join('/') + query = options[:params] if options.key?(:params) + body = options[:body] if options.key?(:body) + headers = options[:headers] if options.key(:headers) + + @client.request(method, uri, query, body, headers) + end + end +end diff --git a/lib/kubeclient/rest_client_wrapper.rb b/lib/kubeclient/rest_client_wrapper.rb new file mode 100644 index 00000000..905e4133 --- /dev/null +++ b/lib/kubeclient/rest_client_wrapper.rb @@ -0,0 +1,35 @@ +require_relative 'http_wrapper' + +module Kubeclient + # Wraps the API of +rest_client+ gem to be used by Kubeclient for making HTTP requests. + class RestClientWrapper < HTTPWrapper + def initialize(url, options) + @client = RestClient::Resource.new(url, options) + end + + attr_reader :client + + def request(method, path = nil, **options) + url = path.nil? ? @client.url : @client[path].url + headers_with_params = create_headers_with_params(options[:headers], options[:params]) + payload = options[:body] + + execute_options = @client.options.merge( + method: method, + url: url, + headers: headers_with_params, + payload: payload + ) + RestClient::Request.execute(execute_options) + end + + private + + # In RestClient, you pass params hash inside the headers hash, in :params key. + def create_headers_with_params(headers, params) + headers_with_params = {}.merge(headers || {}) + headers_with_params[:params] = params if params + headers_with_params + end + end +end diff --git a/test/test_httpclient.rb b/test/test_httpclient.rb new file mode 100644 index 00000000..adbe8b2e --- /dev/null +++ b/test/test_httpclient.rb @@ -0,0 +1,26 @@ +require_relative 'test_helper' + +class HTTPClientTest < MiniTest::Test + def test_create + kubeclient = Kubeclient::Client.new( + 'http://localhost:8000', + 'v1', + http_client_type: 'httpclient' + ) + + assert_instance_of(HTTPClient, kubeclient.http_client) + end + + def test_get_namespaces + stub_core_api_list + stub_request(:get, 'http://localhost:8000/api/v1/namespaces/staging') + .to_return(body: open_test_file('namespace.json'), status: 200) + + kubeclient = Kubeclient::Client.new( + 'http://localhost:8000', + http_client_type: 'httpclient' + ) + + kubeclient.get_entity('namespaces', 'staging') + end +end