diff --git a/lib/datadog/ci/transport/adapters/net.rb b/lib/datadog/ci/transport/adapters/net.rb new file mode 100644 index 00000000..8dc0671c --- /dev/null +++ b/lib/datadog/ci/transport/adapters/net.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require_relative "../../ext/transport" + +module Datadog + module CI + module Transport + module Adapters + # Adapter for Net::HTTP + class Net + attr_reader \ + :hostname, + :port, + :timeout, + :ssl + + def initialize(hostname:, port:, ssl:, timeout_seconds:) + @hostname = hostname + @port = port + @timeout = timeout_seconds + @ssl = ssl + end + + def open(&block) + req = ::Net::HTTP.new(hostname, port) + + req.use_ssl = ssl + req.open_timeout = req.read_timeout = timeout + + req.start(&block) + end + + def call(path:, payload:, headers:, verb:) + if respond_to?(verb) + send(verb, path: path, payload: payload, headers: headers) + else + raise "Unknown HTTP method [#{verb}]" + end + end + + def post(path:, payload:, headers:) + post = ::Net::HTTP::Post.new(path, headers) + post.body = payload + + # Connect and send the request + http_response = open do |http| + http.request(post) + end + + # Build and return response + Response.new(http_response) + end + + class Response + attr_reader :http_response + + def initialize(http_response) + @http_response = http_response + end + + def payload + return @decompressed_payload if defined?(@decompressed_payload) + + if gzipped?(http_response.body) + Datadog.logger.debug("Decompressing gzipped response payload") + @decompressed_payload = Gzip.decompress(http_response.body) + else + http_response.body + end + end + + def header(name) + http_response[name] + end + + def code + http_response.code.to_i + end + + def ok? + code.between?(200, 299) + end + + def unsupported? + code == 415 + end + + def not_found? + code == 404 + end + + def client_error? + code.between?(400, 499) + end + + def server_error? + code.between?(500, 599) + end + + def gzipped?(body) + return false if body.nil? || body.empty? + + # no-dd-sa + first_bytes = body[0, 2] + return false if first_bytes.nil? || first_bytes.empty? + + first_bytes.b == Datadog::CI::Ext::Transport::GZIP_MAGIC_NUMBER + end + + def inspect + "#{super}, http_response:#{http_response}" + end + end + end + end + end + end +end diff --git a/lib/datadog/ci/transport/api/agentless.rb b/lib/datadog/ci/transport/api/agentless.rb index 49b20e52..49c9cbbe 100644 --- a/lib/datadog/ci/transport/api/agentless.rb +++ b/lib/datadog/ci/transport/api/agentless.rb @@ -60,7 +60,7 @@ def build_http_client(url, compress:) Datadog::CI::Transport::HTTP.new( host: uri.host, - port: uri.port, + port: uri.port || 80, ssl: uri.scheme == "https" || uri.port == 443, compress: compress ) diff --git a/lib/datadog/ci/transport/http.rb b/lib/datadog/ci/transport/http.rb index 6069c9d4..798a1b19 100644 --- a/lib/datadog/ci/transport/http.rb +++ b/lib/datadog/ci/transport/http.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true require "delegate" -require "datadog/core/transport/http/adapters/net" -require "datadog/core/transport/http/env" -require "datadog/core/transport/request" require "socket" require_relative "gzip" +require_relative "adapters/net" require_relative "../ext/transport" module Datadog @@ -24,7 +22,7 @@ class HTTP MAX_RETRIES = 3 INITIAL_BACKOFF = 1 - def initialize(host:, timeout: DEFAULT_TIMEOUT, port: nil, ssl: true, compress: false) + def initialize(host:, port:, timeout: DEFAULT_TIMEOUT, ssl: true, compress: false) @host = host @port = port @timeout = timeout @@ -70,7 +68,7 @@ def request( def perform_http_call(path:, payload:, headers:, verb:, retries: MAX_RETRIES, backoff: INITIAL_BACKOFF) adapter.call( - build_env(path: path, payload: payload, headers: headers, verb: verb) + path: path, payload: payload, headers: headers, verb: verb ) rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, SocketError, Net::HTTPBadResponse => e Datadog.logger.debug("Failed to send request with #{e} (#{e.message})") @@ -87,65 +85,18 @@ def perform_http_call(path:, payload:, headers:, verb:, retries: MAX_RETRIES, ba end end - def build_env(path:, payload:, headers:, verb:) - env = Datadog::Core::Transport::HTTP::Env.new( - Datadog::Core::Transport::Request.new - ) - env.body = payload - env.path = path - env.headers = headers - env.verb = verb - env - end - def adapter - settings = AdapterSettings.new(hostname: host, port: port, ssl: ssl, timeout_seconds: timeout) - @adapter ||= Datadog::Core::Transport::HTTP::Adapters::Net.new(settings) + @adapter ||= Datadog::CI::Transport::Adapters::Net.new( + hostname: host, port: port, ssl: ssl, timeout_seconds: timeout + ) end # adds compatibility with Datadog::Tracing transport and # provides ungzipping capabilities class ResponseDecorator < ::SimpleDelegator - def payload - return @decompressed_payload if defined?(@decompressed_payload) - - if gzipped?(__getobj__.payload) - Datadog.logger.debug("Decompressing gzipped response payload") - @decompressed_payload = Gzip.decompress(__getobj__.payload) - else - __getobj__.payload - end - end - def trace_count 0 end - - def gzipped?(payload) - return false if payload.nil? || payload.empty? - - # no-dd-sa - first_bytes = payload[0, 2] - return false if first_bytes.nil? || first_bytes.empty? - - first_bytes.b == Datadog::CI::Ext::Transport::GZIP_MAGIC_NUMBER - end - end - - class AdapterSettings - attr_reader :hostname, :port, :ssl, :timeout_seconds - - def initialize(hostname:, port: nil, ssl: true, timeout_seconds: nil) - @hostname = hostname - @port = port - @ssl = ssl - @timeout_seconds = timeout_seconds - end - - def ==(other) - hostname == other.hostname && port == other.port && ssl == other.ssl && - timeout_seconds == other.timeout_seconds - end end end end diff --git a/sig/datadog/ci/transport/adapters/net.rbs b/sig/datadog/ci/transport/adapters/net.rbs new file mode 100644 index 00000000..65802496 --- /dev/null +++ b/sig/datadog/ci/transport/adapters/net.rbs @@ -0,0 +1,63 @@ +module Datadog + module CI + module Transport + module Adapters + class Net + @hostname: String + + @port: Integer + + @timeout: Float + + @ssl: bool + + attr_reader hostname: String + + attr_reader port: Integer + + attr_reader timeout: Float + + attr_reader ssl: bool + + def initialize: (hostname: String, port: Integer, ssl: bool, timeout_seconds: Integer) -> void + + def open: () { (Net::HTTP http) -> ::Net::HTTPResponse } -> ::Net::HTTPResponse + + def call: (path: String, payload: String, headers: Hash[String, String], verb: String) -> Response + + def post: (path: String, payload: String, headers: Hash[String, String]) -> Response + + class Response + @http_response: ::Net::HTTPResponse + + @decompressed_payload: String + + attr_reader http_response: ::Net::HTTPResponse + + def initialize: (::Net::HTTPResponse http_response) -> void + + def payload: () -> String + + def header: (String name) -> String? + + def code: () -> Integer + + def ok?: () -> bool + + def unsupported?: () -> bool + + def not_found?: () -> bool + + def client_error?: () -> bool + + def server_error?: () -> bool + + def gzipped?: (String body) -> bool + + def inspect: () -> ::String + end + end + end + end + end +end diff --git a/sig/datadog/ci/transport/http.rbs b/sig/datadog/ci/transport/http.rbs index d8c79b1a..573093b0 100644 --- a/sig/datadog/ci/transport/http.rbs +++ b/sig/datadog/ci/transport/http.rbs @@ -10,10 +10,10 @@ module Datadog module CI module Transport class HTTP - @adapter: Datadog::Core::Transport::HTTP::Adapters::Net + @adapter: Datadog::CI::Transport::Adapters::Net attr_reader host: String - attr_reader port: Integer? + attr_reader port: Integer attr_reader ssl: bool attr_reader timeout: Integer attr_reader compress: bool @@ -22,31 +22,15 @@ module Datadog MAX_RETRIES: 3 INITIAL_BACKOFF: 1 - def initialize: (host: String, ?port: Integer?, ?ssl: bool, ?timeout: Integer, ?compress: bool) -> void + def initialize: (host: String, port: Integer, ?ssl: bool, ?timeout: Integer, ?compress: bool) -> void def request: (?verb: String, payload: String, headers: Hash[String, String], path: String, ?retries: Integer, ?backoff: Integer, ?accept_compressed_response: bool) -> ResponseDecorator private - def adapter: () -> Datadog::Core::Transport::HTTP::Adapters::Net + def adapter: () -> Datadog::CI::Transport::Adapters::Net - def build_env: (payload: String, headers: Hash[String, String], path: String, verb: String) -> Datadog::Core::Transport::HTTP::Env - - def perform_http_call: (payload: String, headers: Hash[String, String], path: String, verb: String, ?retries: Integer, ?backoff: Integer) -> Datadog::Core::Transport::Response - - class AdapterSettings - attr_reader hostname: String - attr_reader port: Integer? - attr_reader ssl: bool - attr_reader timeout_seconds: Integer - - @hostname: String - @port: Integer? - @ssl: bool - @timeout_seconds: Integer - - def initialize: (hostname: String, ?port: Integer?, ?ssl: bool, ?timeout_seconds: Integer) -> void - end + def perform_http_call: (payload: String, headers: Hash[String, String], path: String, verb: String, ?retries: Integer, ?backoff: Integer) -> Datadog::CI::Transport::Adapters::Net::Response class ResponseDecorator < ::SimpleDelegator include Datadog::Core::Transport::Response diff --git a/spec/datadog/ci/transport/adapters/net_spec.rb b/spec/datadog/ci/transport/adapters/net_spec.rb new file mode 100644 index 00000000..e23878ba --- /dev/null +++ b/spec/datadog/ci/transport/adapters/net_spec.rb @@ -0,0 +1,296 @@ +require_relative "../../../../../lib/datadog/ci/transport/adapters/net" +require_relative "../../../../../lib/datadog/ci/transport/gzip" + +RSpec.describe Datadog::CI::Transport::Adapters::Net do + subject(:adapter) do + described_class.new(hostname: hostname, port: port, ssl: ssl, timeout_seconds: timeout) + end + + let(:hostname) { "hostname" } + let(:port) { 9999 } + let(:timeout) { 15 } + let(:ssl) { false } + + shared_context "HTTP connection stub" do + let(:http_connection) { instance_double(::Net::HTTP) } + + before do + allow(::Net::HTTP).to receive(:new) + .with( + adapter.hostname, + adapter.port + ).and_return(http_connection) + + allow(http_connection).to receive(:open_timeout=).with(adapter.timeout) + allow(http_connection).to receive(:read_timeout=).with(adapter.timeout) + allow(http_connection).to receive(:use_ssl=).with(adapter.ssl) + + allow(http_connection).to receive(:start).and_yield(http_connection) + end + end + + describe "#initialize" do + context "given a :timeout option" do + let(:timeout) { double("timeout") } + + it { is_expected.to have_attributes(timeout: timeout) } + end + + context "given a :ssl option" do + context "with true" do + let(:ssl) { true } + + it { is_expected.to have_attributes(ssl: true) } + end + end + end + + describe "#open" do + include_context "HTTP connection stub" + + it "opens and yields a Net::HTTP connection" do + expect { |b| adapter.open(&b) }.to yield_with_args(http_connection) + end + end + + describe "#call" do + let(:path) { "/foo" } + let(:body) { "{}" } + let(:headers) { {} } + + subject(:call) { adapter.call(verb: verb, path: path, payload: body, headers: headers) } + + context "given a verb" do + context ":post" do + include_context "HTTP connection stub" + + let(:verb) { :post } + let(:http_response) { double("http_response") } + + context "and an empty form body" do + let(:form) { {} } + let(:post) { instance_double(Net::HTTP::Post) } + + it "makes a POST and produces a response" do + expect(Net::HTTP::Post) + .to receive(:new) + .with(path, headers) + .and_return(post) + + expect(post) + .to receive(:body=) + .with(body) + + expect(http_connection) + .to receive(:request) + .with(post) + .and_return(http_response) + + is_expected.to be_a_kind_of(described_class::Response) + expect(call.http_response).to be(http_response) + end + end + end + + context ":get" do + let(:verb) { :get } + + it { expect { call }.to raise_error("Unknown HTTP method [get]") } + end + end + end + + describe "#post" do + include_context "HTTP connection stub" + + let(:path) { "/foo" } + let(:body) { "{}" } + let(:headers) { {} } + + subject(:post) { adapter.post(path: path, payload: body, headers: headers) } + + let(:http_response) { double("http_response") } + + before { expect(http_connection).to receive(:request).and_return(http_response) } + + it "produces a response" do + is_expected.to be_a_kind_of(described_class::Response) + expect(post.http_response).to be(http_response) + end + end +end + +RSpec.describe Datadog::CI::Transport::Adapters::Net::Response do + subject(:response) { described_class.new(http_response) } + + let(:http_response) { instance_double(::Net::HTTPResponse) } + + describe "#initialize" do + it { is_expected.to have_attributes(http_response: http_response) } + end + + describe "#payload" do + subject(:payload) { response.payload } + + let(:http_response) { instance_double(::Net::HTTPResponse, body: "body") } + + it { is_expected.to be(http_response.body) } + + context "when payload is gzipped" do + let(:expected_payload) { "sample_payload" } + let(:http_response) do + instance_double(::Net::HTTPResponse, body: Datadog::CI::Transport::Gzip.compress(expected_payload)) + end + + it { is_expected.to eq(expected_payload) } + end + end + + describe "#code" do + subject(:code) { response.code } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: "200") } + + it { is_expected.to eq(200) } + end + + describe "#ok?" do + subject(:ok?) { response.ok? } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: code) } + + context "when code is 199" do + let(:code) { 199 } + + it { is_expected.to be false } + end + + context "when code is 200" do + let(:code) { 200 } + + it { is_expected.to be true } + end + + context "when code is 299" do + let(:code) { 299 } + + it { is_expected.to be true } + end + + context "when code is 300" do + let(:code) { 300 } + + it { is_expected.to be false } + end + end + + describe "#unsupported?" do + subject(:unsupported?) { response.unsupported? } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: code) } + + context "when code is 400" do + let(:code) { 400 } + + it { is_expected.to be false } + end + + context "when code is 415" do + let(:code) { 415 } + + it { is_expected.to be true } + end + end + + describe "#not_found?" do + subject(:not_found?) { response.not_found? } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: code) } + + context "when code is 400" do + let(:code) { 400 } + + it { is_expected.to be false } + end + + context "when code is 404" do + let(:code) { 404 } + + it { is_expected.to be true } + end + end + + describe "#client_error?" do + subject(:client_error?) { response.client_error? } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: code) } + + context "when code is 399" do + let(:code) { 399 } + + it { is_expected.to be false } + end + + context "when code is 400" do + let(:code) { 400 } + + it { is_expected.to be true } + end + + context "when code is 499" do + let(:code) { 499 } + + it { is_expected.to be true } + end + + context "when code is 500" do + let(:code) { 500 } + + it { is_expected.to be false } + end + end + + describe "#server_error?" do + subject(:server_error?) { response.server_error? } + + let(:http_response) { instance_double(::Net::HTTPResponse, code: code) } + + context "when code is 499" do + let(:code) { 499 } + + it { is_expected.to be false } + end + + context "when code is 500" do + let(:code) { 500 } + + it { is_expected.to be true } + end + + context "when code is 599" do + let(:code) { 599 } + + it { is_expected.to be true } + end + + context "when code is 600" do + let(:code) { 600 } + + it { is_expected.to be false } + end + end + + describe "#header" do + subject(:header) { response.header(name) } + + let(:name) { "name" } + let(:value) { "value" } + let(:http_response) { instance_double(::Net::HTTPResponse) } + + before do + expect(http_response).to receive(:[]).with(name).and_return(value) + end + + it { is_expected.to eq(value) } + end +end diff --git a/spec/datadog/ci/transport/http_spec.rb b/spec/datadog/ci/transport/http_spec.rb index ad2afc5e..3c87f2f9 100644 --- a/spec/datadog/ci/transport/http_spec.rb +++ b/spec/datadog/ci/transport/http_spec.rb @@ -1,7 +1,7 @@ require_relative "../../../../lib/datadog/ci/transport/http" RSpec.describe Datadog::CI::Transport::HTTP do - subject(:transport) { described_class.new(host: host, **options) } + subject(:transport) { described_class.new(host: host, port: port, **options) } let(:host) { "datadog-host" } let(:port) { 8132 } @@ -10,17 +10,16 @@ let(:options) { {} } shared_context "HTTP adapter stub" do - let(:adapter) { instance_double(::Datadog::Core::Transport::HTTP::Adapters::Net) } + let(:adapter) { instance_double(::Datadog::CI::Transport::Adapters::Net) } before do - settings = Datadog::CI::Transport::HTTP::AdapterSettings.new( - hostname: transport.host, - port: transport.port, - timeout_seconds: transport.timeout, - ssl: transport.ssl - ) - allow(::Datadog::Core::Transport::HTTP::Adapters::Net).to receive(:new) - .with(settings).and_return(adapter) + allow(::Datadog::CI::Transport::Adapters::Net).to receive(:new) + .with( + hostname: transport.host, + port: transport.port, + timeout_seconds: transport.timeout, + ssl: transport.ssl + ).and_return(adapter) end end @@ -31,7 +30,7 @@ it do is_expected.to have_attributes( host: host, - port: nil, + port: port, timeout: Datadog::CI::Transport::HTTP::DEFAULT_TIMEOUT, ssl: true ) @@ -98,19 +97,18 @@ subject(:response) { transport.request(path: path, payload: payload, headers: headers, **request_options) } context "when request is successful" do - let(:expected_env) do - env = Datadog::Core::Transport::HTTP::Env.new( - Datadog::Core::Transport::Request.new - ) - env.body = payload - env.path = path - env.headers = expected_headers - env.verb = "post" - env - end + let(:expected_payload) { payload } + let(:expected_path) { path } + let(:expected_headers) { headers } + let(:expected_verb) { "post" } before do - expect(adapter).to receive(:call).with(expected_env).and_return(http_response) + expect(adapter).to receive(:call).with( + payload: expected_payload, + path: expected_path, + headers: expected_headers, + verb: expected_verb + ).and_return(http_response) end it "produces a response" do @@ -124,25 +122,7 @@ let(:expected_headers) { {"Content-Type" => "application/json", "Accept-Encoding" => "gzip"} } let(:request_options) { {accept_compressed_response: true} } - it "adds Accept-Encoding header" do - is_expected.to be_a_kind_of(described_class::ResponseDecorator) - - expect(response.code).to eq(200) - expect(response.payload).to eq("sample payload") - end - - context "when response is gzipped" do - let(:response_payload) do - Datadog::CI::Transport::Gzip.compress("sample payload") - end - - it "decompressed response payload" do - is_expected.to be_a_kind_of(described_class::ResponseDecorator) - - expect(response.code).to eq(200) - expect(response.payload).to eq("sample payload") - end - end + it { is_expected.to be_a_kind_of(described_class::ResponseDecorator) } end end @@ -152,19 +132,18 @@ let(:options) { {compress: true} } let(:post_request) { double(:post_request) } - let(:env) do - env = Datadog::Core::Transport::HTTP::Env.new( - Datadog::Core::Transport::Request.new - ) - env.body = Datadog::CI::Transport::Gzip.compress(payload) - env.path = path - env.headers = headers - env.verb = "post" - env - end + let(:expected_payload) { Datadog::CI::Transport::Gzip.compress(payload) } + let(:expected_path) { path } + let(:expected_headers) { headers } + let(:expected_verb) { "post" } before do - expect(adapter).to receive(:call).with(env).and_return(http_response) + expect(adapter).to receive(:call).with( + payload: expected_payload, + path: expected_path, + headers: expected_headers, + verb: expected_verb + ).and_return(http_response) end it "produces a response" do