From eb3d3f90484052cb3c67f82a50a6ea48eb22dea4 Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Wed, 10 Aug 2022 22:59:06 +0100 Subject: [PATCH 1/4] implementation of the webdav plugin --- docker-compose.yml | 9 ++ lib/httpx/extensions.rb | 4 - lib/httpx/plugins/webdav.rb | 78 +++++++++++++++++ test/http_test.rb | 1 + test/https_test.rb | 1 + test/support/requests/plugins/webdav.rb | 106 ++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 lib/httpx/plugins/webdav.rb create mode 100644 test/support/requests/plugins/webdav.rb diff --git a/docker-compose.yml b/docker-compose.yml index c8e754b1..4d5040ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - AWS_ACCESS_KEY_ID=test - AWS_SECRET_ACCESS_KEY=test - AMZ_HOST=aws:4566 + - WEBDAV_HOST=webdav image: ruby:alpine privileged: true depends_on: @@ -34,6 +35,7 @@ services: - nghttp2 - aws - ws-echo-server + - webdav volumes: - ./:/home links: @@ -137,3 +139,10 @@ services: ports: - 8083:80 image: jmalloc/echo-server + + webdav: + image: bytemark/webdav + environment: + - AUTH_TYPE=Basic + - USERNAME=user + - PASSWORD=pass \ No newline at end of file diff --git a/lib/httpx/extensions.rb b/lib/httpx/extensions.rb index 2dd201b2..66397d4b 100644 --- a/lib/httpx/extensions.rb +++ b/lib/httpx/extensions.rb @@ -143,10 +143,6 @@ module HTTPX end module RegexpExtensions - # If you wonder why this is there: the oauth feature uses a refinement to enhance the - # Regexp class locally with #match? , but this is never tested, because ActiveSupport - # monkey-patches the same method... Please ActiveSupport, stop being so intrusive! - # :nocov: refine(Regexp) do def match?(*args) !match(*args).nil? diff --git a/lib/httpx/plugins/webdav.rb b/lib/httpx/plugins/webdav.rb new file mode 100644 index 00000000..4af02959 --- /dev/null +++ b/lib/httpx/plugins/webdav.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module HTTPX + module Plugins + # + # This plugin implements convenience methods for performing WEBDAV requests. + # + # https://gitlab.com/honeyryderchuck/httpx/wikis/WEBDAV + # + module WebDav + module InstanceMethods + def copy(src, dest) + request(:copy, src, headers: { "destination" => @options.origin.merge(dest) }) + end + + def move(src, dest) + request(:move, src, headers: { "destination" => @options.origin.merge(dest) }) + end + + def lock(path, timeout: nil, &blk) + headers = {} + headers["timeout"] = if timeout && timeout.positive? + "Second-#{timeout}" + else + "Infinite, Second-4100000000" + end + xml = "" \ + "" \ + "" \ + "" \ + "null" \ + "" + response = request(:lock, path, headers: headers, xml: xml) + + return response unless blk && response.status == 200 + + lock_token = response.headers["lock-token"] + + begin + blk.call(response) + ensure + unlock(path, lock_token) + end + end + + def unlock(path, lock_token) + request(:unlock, path, headers: { "lock-token" => lock_token }) + end + + def mkcol(dir) + request(:mkcol, dir) + end + + def propfind(path, xml = nil) + body = case xml + when :acl + '' \ + "" + when nil + '' + else + xml + end + + request(:propfind, path, headers: { "depth" => "1" }, xml: body) + end + + def proppatch(path, xml) + body = "" \ + "#{xml}" + request(:proppatch, path, xml: body) + end + # %i[ orderpatch acl report search] + end + end + register_plugin(:webdav, WebDav) + end +end diff --git a/test/http_test.rb b/test/http_test.rb index 0ccedfe4..5d51ddc8 100644 --- a/test/http_test.rb +++ b/test/http_test.rb @@ -32,6 +32,7 @@ class HTTPTest < Minitest::Test include Plugins::GRPC if RUBY_ENGINE == "ruby" && RUBY_VERSION >= "2.3.0" include Plugins::ResponseCache include Plugins::CircuitBreaker + include Plugins::WebDav def test_verbose_log log = StringIO.new diff --git a/test/https_test.rb b/test/https_test.rb index 0ce9c6ae..e3792952 100644 --- a/test/https_test.rb +++ b/test/https_test.rb @@ -33,6 +33,7 @@ class HTTPSTest < Minitest::Test include Plugins::GRPC if RUBY_ENGINE == "ruby" && RUBY_VERSION >= "2.3.0" include Plugins::ResponseCache include Plugins::CircuitBreaker + include Plugins::WebDav def test_connection_coalescing coalesced_origin = "https://#{ENV["HTTPBIN_COALESCING_HOST"]}" diff --git a/test/support/requests/plugins/webdav.rb b/test/support/requests/plugins/webdav.rb new file mode 100644 index 00000000..53e37ef0 --- /dev/null +++ b/test/support/requests/plugins/webdav.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Requests + module Plugins + module WebDav + def test_plugin_webdav_mkcol + # put file + webdav_client.delete("/mkcol_dir_test/") + + response = webdav_client.mkcol("/mkcol_dir_test/") + verify_status(response, 201) + end + + def test_plugin_webdav_copy + # put file + webdav_client.delete("/copied_copy.html") + webdav_client.put("/copy.html", body: "") + + response = webdav_client.get("/copied_copy.html") + verify_status(response, 404) + response = webdav_client.copy("/copy.html", "/copied_copy.html") + verify_status(response, 201) + response = webdav_client.get("/copied_copy.html") + verify_status(response, 200) + response = webdav_client.get("/copy.html") + verify_status(response, 200) + end + + def test_plugin_webdav_move + # put file + webdav_client.delete("/moved_move.html") + webdav_client.put("/move.html", body: "") + + response = webdav_client.get("/moved_move.html") + verify_status(response, 404) + response = webdav_client.move("/move.html", "/moved_move.html") + verify_status(response, 201) + response = webdav_client.get("/move.html") + verify_status(response, 404) + response = webdav_client.get("/moved_move.html") + verify_status(response, 200) + end + + def test_plugin_webdav_lock + # put file + webdav_client.put("/lockfile.html", body: "bang") + response = webdav_client.lock("/lockfile.html") + verify_status(response, 200) + lock_token = response.headers["lock-token"] + + response = webdav_client.delete("/lockfile.html") + verify_status(response, 423) + + response = webdav_client.unlock("/lockfile.html", lock_token) + verify_status(response, 204) + + response = webdav_client.delete("/lockfile.html") + verify_status(response, 204) + end + + def test_plugin_webdav_lock_blk + # put file + webdav_client.put("/lockfileblk.html", body: "bang") + webdav_client.lock("/lockfileblk.html") do |response| + verify_status(response, 200) + + response = webdav_client.delete("/lockfileblk.html") + verify_status(response, 423) + end + response = webdav_client.delete("/lockfileblk.html") + verify_status(response, 204) + end + + def test_plugin_webdav_propfind_proppatch + # put file + webdav_client.put("/propfind.html", body: "bang") + response = webdav_client.propfind("/propfind.html") + verify_status(response, 207) + xml = "" \ + "" \ + "" \ + "Jim Bean" \ + "" \ + "" \ + "" + response = webdav_client.proppatch("/propfind.html", xml) + verify_status(response, 207) + + response = webdav_client.propfind("/propfind.html") + verify_status(response, 207) + assert response.to_s.include?("Jim Bean") + end + + private + + def webdav_client + @webdav_client ||= HTTPX.plugin(:basic_authentication).plugin(:webdav, origin: start_webdav_server).basic_auth("user", "pass") + end + + def start_webdav_server + origin = ENV.fetch("WEBDAV_HOST") + "http://#{origin}" + end + end + end +end From 49d6cc4da4874041f98504849766826f6147d78f Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Sat, 13 Aug 2022 14:07:14 +0100 Subject: [PATCH 2/4] remove http verb checks, strictness does not benefit experimentation --- lib/httpx/request.rb | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/lib/httpx/request.rb b/lib/httpx/request.rb index 49512cca..7412ad98 100644 --- a/lib/httpx/request.rb +++ b/lib/httpx/request.rb @@ -9,29 +9,6 @@ module HTTPX include Callbacks using URIExtensions - METHODS = [ - # RFC 2616: Hypertext Transfer Protocol -- HTTP/1.1 - :options, :get, :head, :post, :put, :delete, :trace, :connect, - - # RFC 2518: HTTP Extensions for Distributed Authoring -- WEBDAV - :propfind, :proppatch, :mkcol, :copy, :move, :lock, :unlock, - - # RFC 3648: WebDAV Ordered Collections Protocol - :orderpatch, - - # RFC 3744: WebDAV Access Control Protocol - :acl, - - # RFC 6352: vCard Extensions to WebDAV -- CardDAV - :report, - - # RFC 5789: PATCH Method for HTTP - :patch, - - # draft-reschke-webdav-search: WebDAV Search - :search - ].freeze - USER_AGENT = "httpx.rb/#{VERSION}" attr_reader :verb, :uri, :headers, :body, :state, :options, :response @@ -54,8 +31,6 @@ module HTTPX @uri = origin.merge("#{base_path}#{@uri}") end - raise(Error, "unknown method: #{verb}") unless METHODS.include?(@verb) - @headers = @options.headers_class.new(@options.headers) @headers["user-agent"] ||= USER_AGENT @headers["accept"] ||= "*/*" From c70209db4b198a6f43dd2d0e67b61e27154878dc Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Sat, 13 Aug 2022 14:55:07 +0100 Subject: [PATCH 3/4] added xml transcoder capabilities to encod/decode xml, expects nokogiri. --- Gemfile | 1 + lib/httpx/options.rb | 4 +-- lib/httpx/request.rb | 2 ++ lib/httpx/response.rb | 4 +++ lib/httpx/transcoder.rb | 1 + lib/httpx/transcoder/xml.rb | 49 +++++++++++++++++++++++++++++++++++++ test/options_test.rb | 3 ++- test/request_test.rb | 7 ++++++ test/response_test.rb | 5 ++++ 9 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 lib/httpx/transcoder/xml.rb diff --git a/Gemfile b/Gemfile index 24287f0c..43cb800c 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ group :test do else gem "webmock" end + gem "nokogiri" gem "websocket-driver" gem "net-ssh", "~> 4.2.0" if RUBY_VERSION < "2.2.0" diff --git a/lib/httpx/options.rb b/lib/httpx/options.rb index c4ca684b..2bd97094 100644 --- a/lib/httpx/options.rb +++ b/lib/httpx/options.rb @@ -201,7 +201,7 @@ module HTTPX end %i[ - params form json body ssl http2_settings + params form json xml body ssl http2_settings request_class response_class headers_class request_body_class response_body_class connection_class options_class io fallback_protocol debug debug_level transport_options resolver_class resolver_options @@ -210,7 +210,7 @@ module HTTPX def_option(method_name) end - REQUEST_IVARS = %i[@params @form @json @body].freeze + REQUEST_IVARS = %i[@params @form @xml @json @body].freeze private_constant :REQUEST_IVARS def ==(other) diff --git a/lib/httpx/request.rb b/lib/httpx/request.rb index 7412ad98..d98706a5 100644 --- a/lib/httpx/request.rb +++ b/lib/httpx/request.rb @@ -162,6 +162,8 @@ module HTTPX Transcoder.registry("form").encode(options.form) elsif options.json Transcoder.registry("json").encode(options.json) + elsif options.xml + Transcoder.registry("xml").encode(options.xml) end return if @body.nil? diff --git a/lib/httpx/response.rb b/lib/httpx/response.rb index a4923a46..ce6b338b 100644 --- a/lib/httpx/response.rb +++ b/lib/httpx/response.rb @@ -94,6 +94,10 @@ module HTTPX decode("form") end + def xml + decode("xml") + end + private def decode(format, *args) diff --git a/lib/httpx/transcoder.rb b/lib/httpx/transcoder.rb index 9b0d985b..a9bd1099 100644 --- a/lib/httpx/transcoder.rb +++ b/lib/httpx/transcoder.rb @@ -90,4 +90,5 @@ end require "httpx/transcoder/body" require "httpx/transcoder/form" require "httpx/transcoder/json" +require "httpx/transcoder/xml" require "httpx/transcoder/chunker" diff --git a/lib/httpx/transcoder/xml.rb b/lib/httpx/transcoder/xml.rb new file mode 100644 index 00000000..969ec51c --- /dev/null +++ b/lib/httpx/transcoder/xml.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "delegate" +require "forwardable" +require "uri" + +module HTTPX::Transcoder + module Xml + using HTTPX::RegexpExtensions + + module_function + + MIME_TYPES = %r{\b(application|text)/(.+\+)?xml\b}.freeze + + class Encoder < SimpleDelegator + def content_type + charset = respond_to?(:encoding) ? encoding.to_s.downcase : "utf-8" + "application/xml; charset=#{charset}" + end + + def bytesize + to_s.bytesize + end + end + + def encode(xml) + Encoder.new(xml) + end + + begin + require "nokogiri" + + # rubocop:disable Lint/DuplicateMethods + def decode(response) + content_type = response.content_type.mime_type + + raise HTTPX::Error, "invalid form mime type (#{content_type})" unless MIME_TYPES.match?(content_type) + + Nokogiri::XML.method(:parse) + end + rescue LoadError + def decode(_response) + raise HTTPX::Error, "\"nokogiri\" is required in order to decode XML" + end + end + # rubocop:enable Lint/DuplicateMethods + end + register "xml", Xml +end diff --git a/test/options_test.rb b/test/options_test.rb index abab57e6..8d031fa9 100644 --- a/test/options_test.rb +++ b/test/options_test.rb @@ -45,7 +45,7 @@ class OptionsTest < Minitest::Test assert opt2.body == "fat", "body was not set" end - %i[form json].each do |meth| + %i[form json xml].each do |meth| define_method :"test_options_#{meth}" do opt1 = Options.new assert opt1.public_send(meth).nil?, "#{meth} shouldn't be set by default" @@ -98,6 +98,7 @@ class OptionsTest < Minitest::Test :debug_level => 1, :params => nil, :json => nil, + :xml => nil, :body => nil, :window_size => 16_384, :body_threshold_size => 114_688, diff --git a/test/request_test.rb b/test/request_test.rb index 22f750cf..ec0b77cc 100644 --- a/test/request_test.rb +++ b/test/request_test.rb @@ -77,6 +77,13 @@ class RequestTest < Minitest::Test assert req.headers["content-length"] == "13", "content length is wrong" end + def test_request_body_xml + req = Request.new(:post, "http://example.com/", xml: "") + assert !req.body.empty?, "body should exist" + assert req.headers["content-type"] == "application/xml; charset=utf-8", "content type is wrong" + assert req.headers["content-length"] == "11", "content length is wrong" + end + private def resource diff --git a/test/response_test.rb b/test/response_test.rb index 98e6c94d..0c669b77 100644 --- a/test/response_test.rb +++ b/test/response_test.rb @@ -156,6 +156,11 @@ class ResponseTest < Minitest::Test form_response << "богус" assert_raises(ArgumentError) { form_response.form } + xml_response = Response.new(request, 200, "2.0", { "content-type" => "application/xml; charset=utf-8" }) + xml_response << "" + xml = xml_response.xml + assert xml.is_a?(Nokogiri::XML::Node) + form2_response = Response.new(request, 200, "2.0", { "content-type" => "application/x-www-form-urlencoded" }) form2_response << "a[]=b&a[]=c&d[e]=f&g[h][i][j]=k&l[m][][n]=o&l[m][][p]=q&l[m][][n]=r&s[=t" assert form2_response.form == { From 535a30db25ae3fcec91c0cac2518b6178531ea23 Mon Sep 17 00:00:00 2001 From: HoneyryderChuck Date: Sat, 13 Aug 2022 16:03:57 +0100 Subject: [PATCH 4/4] fixing issues around typing --- lib/httpx/transcoder/xml.rb | 14 +++++++++++--- sig/transcoder/json.rbs | 2 +- sig/transcoder/xml.rbs | 21 +++++++++++++++++++++ test/support/ci/build.sh | 2 +- 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 sig/transcoder/xml.rbs diff --git a/lib/httpx/transcoder/xml.rb b/lib/httpx/transcoder/xml.rb index 969ec51c..3bfa094d 100644 --- a/lib/httpx/transcoder/xml.rb +++ b/lib/httpx/transcoder/xml.rb @@ -12,14 +12,22 @@ module HTTPX::Transcoder MIME_TYPES = %r{\b(application|text)/(.+\+)?xml\b}.freeze - class Encoder < SimpleDelegator + class Encoder + def initialize(xml) + @raw = xml + end + def content_type - charset = respond_to?(:encoding) ? encoding.to_s.downcase : "utf-8" + charset = @raw.respond_to?(:encoding) ? @raw.encoding.to_s.downcase : "utf-8" "application/xml; charset=#{charset}" end def bytesize - to_s.bytesize + @raw.to_s.bytesize + end + + def to_s + @raw.to_s end end diff --git a/sig/transcoder/json.rbs b/sig/transcoder/json.rbs index 729a1049..abf8b4f2 100644 --- a/sig/transcoder/json.rbs +++ b/sig/transcoder/json.rbs @@ -20,7 +20,7 @@ module HTTPX::Transcoder private - def initialize: (_ToJson json) -> untyped + def initialize: (_ToJson json) -> void end end end diff --git a/sig/transcoder/xml.rbs b/sig/transcoder/xml.rbs new file mode 100644 index 00000000..3abc0902 --- /dev/null +++ b/sig/transcoder/xml.rbs @@ -0,0 +1,21 @@ +module HTTPX::Transcoder + module XML + + def self?.encode: (untyped xml) -> Encoder + def self?.decode: (HTTPX::Response response) -> _Decoder + + class Encoder + @raw: untyped # can be nokogiri object + + def content_type: () -> String + + def bytesize: () -> (Integer | Float) + + def to_s: () -> String + + private + + def initialize: (String xml) -> void + end + end +end diff --git a/test/support/ci/build.sh b/test/support/ci/build.sh index 7f08a8cf..acec247a 100755 --- a/test/support/ci/build.sh +++ b/test/support/ci/build.sh @@ -10,7 +10,7 @@ RUBY_ENGINE=`ruby -e 'puts RUBY_ENGINE'` IPTABLES=iptables-translate if [[ "$RUBY_ENGINE" = "truffleruby" ]]; then - dnf install -y iptables iproute which file idn2 git + dnf install -y iptables iproute which file idn2 git xz elif [[ "$RUBY_PLATFORM" = "java" ]]; then apt-get update && apt-get install -y build-essential iptables iproute2 file idn2 git elif [[ ${RUBY_VERSION:0:3} = "2.1" ]]; then