diff --git a/Library/Homebrew/livecheck.rb b/Library/Homebrew/livecheck.rb index 0dc87c91b6..ebfcc7ec52 100644 --- a/Library/Homebrew/livecheck.rb +++ b/Library/Homebrew/livecheck.rb @@ -20,6 +20,14 @@ class Livecheck sig { returns(T.nilable(String)) } attr_reader :skip_msg + # A block used by strategies to identify version information. + sig { returns(T.nilable(Proc)) } + attr_reader :strategy_block + + # Options used by `Strategy` methods to modify `curl` behavior. + sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) } + attr_reader :url_options + sig { params(package_or_resource: T.any(Cask::Cask, T.class_of(Formula), Resource)).void } def initialize(package_or_resource) @package_or_resource = package_or_resource @@ -32,6 +40,7 @@ class Livecheck @strategy_block = T.let(nil, T.nilable(Proc)) @throttle = T.let(nil, T.nilable(Integer)) @url = T.let(nil, T.any(NilClass, String, Symbol)) + @url_options = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped])) end # Sets the `@referenced_cask_name` instance variable to the provided `String` @@ -134,9 +143,6 @@ class Livecheck end end - sig { returns(T.nilable(Proc)) } - attr_reader :strategy_block - # Sets the `@throttle` instance variable to the provided `Integer` or returns # the `@throttle` instance variable when no argument is provided. sig { @@ -158,13 +164,22 @@ class Livecheck # `@url` instance variable when no argument is provided. The argument can be # a `String` (a URL) or a supported `Symbol` corresponding to a URL in the # formula/cask/resource (e.g. `:stable`, `:homepage`, `:head`, `:url`). + # Any options provided to the method are passed through to `Strategy` methods + # (`page_headers`, `page_content`). sig { params( # URL to check for version information. - url: T.any(String, Symbol), + url: T.any(String, Symbol), + post_form: T.nilable(T::Hash[T.any(String, Symbol), String]), + post_json: T.nilable(T::Hash[T.any(String, Symbol), String]), ).returns(T.nilable(T.any(String, Symbol))) } - def url(url = T.unsafe(nil)) + def url(url = T.unsafe(nil), post_form: nil, post_json: nil) + raise ArgumentError, "Only use `post_form` or `post_json`, not both" if post_form && post_json + + options = { post_form:, post_json: }.compact + @url_options = options if options.present? + case url when nil @url @@ -183,14 +198,15 @@ class Livecheck sig { returns(T::Hash[String, T.untyped]) } def to_hash { - "cask" => @referenced_cask_name, - "formula" => @referenced_formula_name, - "regex" => @regex, - "skip" => @skip, - "skip_msg" => @skip_msg, - "strategy" => @strategy, - "throttle" => @throttle, - "url" => @url, + "cask" => @referenced_cask_name, + "formula" => @referenced_formula_name, + "regex" => @regex, + "skip" => @skip, + "skip_msg" => @skip_msg, + "strategy" => @strategy, + "throttle" => @throttle, + "url" => @url, + "url_options" => @url_options, } end end diff --git a/Library/Homebrew/livecheck/livecheck.rb b/Library/Homebrew/livecheck/livecheck.rb index 609e830941..4c0832dec0 100644 --- a/Library/Homebrew/livecheck/livecheck.rb +++ b/Library/Homebrew/livecheck/livecheck.rb @@ -614,6 +614,7 @@ module Homebrew referenced_livecheck = referenced_formula_or_cask&.livecheck livecheck_url = livecheck.url || referenced_livecheck&.url + livecheck_url_options = livecheck.url_options || referenced_livecheck&.url_options livecheck_regex = livecheck.regex || referenced_livecheck&.regex livecheck_strategy = livecheck.strategy || referenced_livecheck&.strategy livecheck_strategy_block = livecheck.strategy_block || referenced_livecheck&.strategy_block @@ -673,6 +674,7 @@ module Homebrew elsif original_url.present? && original_url != "None" puts "URL: #{original_url}" end + puts "URL Options: #{livecheck_url_options}" if livecheck_url_options.present? puts "URL (processed): #{url}" if url != original_url if strategies.present? && verbose puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}" @@ -701,6 +703,7 @@ module Homebrew strategy_args = { regex: livecheck_regex, + url_options: livecheck_url_options, homebrew_curl:, } # TODO: Set `cask`/`url` args based on the presence of the keyword arg @@ -807,6 +810,7 @@ module Homebrew version_info[:meta][:url][:strategy] = strategy_data[:url] end version_info[:meta][:url][:final] = strategy_data[:final_url] if strategy_data[:final_url] + version_info[:meta][:url][:options] = livecheck_url_options if livecheck_url_options.present? version_info[:meta][:url][:homebrew_curl] = homebrew_curl if homebrew_curl.present? end version_info[:meta][:strategy] = strategy_name if strategy.present? @@ -856,6 +860,7 @@ module Homebrew livecheck = resource.livecheck livecheck_reference = livecheck.formula livecheck_url = livecheck.url + livecheck_url_options = livecheck.url_options livecheck_regex = livecheck.regex livecheck_strategy = livecheck.strategy livecheck_strategy_block = livecheck.strategy_block @@ -893,6 +898,7 @@ module Homebrew elsif original_url.present? && original_url != "None" puts "URL: #{original_url}" end + puts "URL Options: #{livecheck_url_options}" if livecheck_url_options.present? puts "URL (processed): #{url}" if url != original_url if strategies.present? && verbose puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}" @@ -923,6 +929,7 @@ module Homebrew strategy_args = { url:, regex: livecheck_regex, + url_options: livecheck_url_options, homebrew_curl: false, }.compact @@ -1012,6 +1019,7 @@ module Homebrew resource_version_info[:meta][:url][:strategy] = strategy_data[:url] end resource_version_info[:meta][:url][:final] = strategy_data[:final_url] if strategy_data&.dig(:final_url) + resource_version_info[:meta][:url][:options] = livecheck_url_options if livecheck_url_options.present? end resource_version_info[:meta][:strategy] = strategy_name if strategy.present? if strategies.present? diff --git a/Library/Homebrew/livecheck/strategy.rb b/Library/Homebrew/livecheck/strategy.rb index be885df754..950c8578e7 100644 --- a/Library/Homebrew/livecheck/strategy.rb +++ b/Library/Homebrew/livecheck/strategy.rb @@ -166,20 +166,59 @@ module Homebrew end end + # Creates `curl` `--data` or `--json` arguments (for `POST` requests`) + # from related `livecheck` block `url` options. + # + # @param post_form [Hash, nil] data to encode using `URI::encode_www_form` + # @param post_json [Hash, nil] data to encode using `JSON::generate` + # @return [Array] + sig { + params( + post_form: T.nilable(T::Hash[T.any(String, Symbol), String]), + post_json: T.nilable(T::Hash[T.any(String, Symbol), String]), + ).returns(T::Array[String]) + } + def post_args(post_form: nil, post_json: nil) + if post_form.present? + require "uri" + ["--data", URI.encode_www_form(post_form)] + elsif post_json.present? + require "json" + ["--json", JSON.generate(post_json)] + else + [] + end + end + # Collects HTTP response headers, starting with the provided URL. # Redirections will be followed and all the response headers are # collected into an array of hashes. # # @param url [String] the URL to fetch + # @param url_options [Hash] options to modify curl behavior # @param homebrew_curl [Boolean] whether to use brewed curl with the URL # @return [Array] - sig { params(url: String, homebrew_curl: T::Boolean).returns(T::Array[T::Hash[String, String]]) } - def self.page_headers(url, homebrew_curl: false) + sig { + params( + url: String, + url_options: T::Hash[Symbol, T.untyped], + homebrew_curl: T::Boolean, + ).returns(T::Array[T::Hash[String, String]]) + } + def self.page_headers(url, url_options: {}, homebrew_curl: false) headers = [] + if url_options[:post_form].present? || url_options[:post_json].present? + curl_post_args = ["--request", "POST", *post_args( + post_form: url_options[:post_form], + post_json: url_options[:post_json], + )] + end + [:default, :browser].each do |user_agent| begin parsed_output = curl_headers( + *curl_post_args, "--max-redirs", MAX_REDIRECTIONS.to_s, url, @@ -205,13 +244,28 @@ module Homebrew # array with the error message instead. # # @param url [String] the URL of the content to check + # @param url_options [Hash] options to modify curl behavior # @param homebrew_curl [Boolean] whether to use brewed curl with the URL # @return [Hash] - sig { params(url: String, homebrew_curl: T::Boolean).returns(T::Hash[Symbol, T.untyped]) } - def self.page_content(url, homebrew_curl: false) + sig { + params( + url: String, + url_options: T::Hash[Symbol, T.untyped], + homebrew_curl: T::Boolean, + ).returns(T::Hash[Symbol, T.untyped]) + } + def self.page_content(url, url_options: {}, homebrew_curl: false) + if url_options[:post_form].present? || url_options[:post_json].present? + curl_post_args = ["--request", "POST", *post_args( + post_form: url_options[:post_form], + post_json: url_options[:post_json], + )] + end + stderr = T.let(nil, T.nilable(String)) [:default, :browser].each do |user_agent| stdout, stderr, status = curl_output( + *curl_post_args, *PAGE_CONTENT_CURL_ARGS, url, **DEFAULT_CURL_OPTIONS, use_homebrew_curl: homebrew_curl || !curl_supports_fail_with_body?, diff --git a/Library/Homebrew/livecheck/strategy.rbi b/Library/Homebrew/livecheck/strategy.rbi new file mode 100644 index 0000000000..218530f2c3 --- /dev/null +++ b/Library/Homebrew/livecheck/strategy.rbi @@ -0,0 +1,9 @@ +# typed: strict + +module Homebrew + module Livecheck + module Strategy + include Kernel + end + end +end diff --git a/Library/Homebrew/livecheck/strategy/crate.rb b/Library/Homebrew/livecheck/strategy/crate.rb index 7c6f942353..e37e3da27d 100644 --- a/Library/Homebrew/livecheck/strategy/crate.rb +++ b/Library/Homebrew/livecheck/strategy/crate.rb @@ -81,11 +81,11 @@ module Homebrew regex: T.nilable(Regexp), provided_content: T.nilable(String), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **unused, &block) match_data = { matches: {}, regex:, url: } match_data[:cached] = true if provided_content.is_a?(String) @@ -97,7 +97,13 @@ module Homebrew content = if provided_content provided_content else - match_data.merge!(Strategy.page_content(match_data[:url], homebrew_curl:)) + match_data.merge!( + Strategy.page_content( + match_data[:url], + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ), + ) match_data[:content] end return match_data unless content diff --git a/Library/Homebrew/livecheck/strategy/header_match.rb b/Library/Homebrew/livecheck/strategy/header_match.rb index 460f8025f7..e6f2c3a3aa 100644 --- a/Library/Homebrew/livecheck/strategy/header_match.rb +++ b/Library/Homebrew/livecheck/strategy/header_match.rb @@ -74,14 +74,18 @@ module Homebrew url: String, regex: T.nilable(Regexp), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, homebrew_curl: false, **unused, &block) match_data = { matches: {}, regex:, url: } - headers = Strategy.page_headers(url, homebrew_curl:) + headers = Strategy.page_headers( + url, + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ) # Merge the headers from all responses into one hash merged_headers = headers.reduce(&:merge) diff --git a/Library/Homebrew/livecheck/strategy/json.rb b/Library/Homebrew/livecheck/strategy/json.rb index b8f05e29ae..e7ac5e51d4 100644 --- a/Library/Homebrew/livecheck/strategy/json.rb +++ b/Library/Homebrew/livecheck/strategy/json.rb @@ -102,11 +102,11 @@ module Homebrew regex: T.nilable(Regexp), provided_content: T.nilable(String), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **unused, &block) raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank? match_data = { matches: {}, regex:, url: } @@ -116,7 +116,13 @@ module Homebrew match_data[:cached] = true provided_content else - match_data.merge!(Strategy.page_content(url, homebrew_curl:)) + match_data.merge!( + Strategy.page_content( + url, + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ), + ) match_data[:content] end return match_data if content.blank? diff --git a/Library/Homebrew/livecheck/strategy/page_match.rb b/Library/Homebrew/livecheck/strategy/page_match.rb index 8f68676b54..7f2daaae1d 100644 --- a/Library/Homebrew/livecheck/strategy/page_match.rb +++ b/Library/Homebrew/livecheck/strategy/page_match.rb @@ -85,11 +85,11 @@ module Homebrew regex: T.nilable(Regexp), provided_content: T.nilable(String), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **unused, &block) if regex.blank? && block.blank? raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a regex or `strategy` block" end @@ -101,7 +101,13 @@ module Homebrew match_data[:cached] = true provided_content else - match_data.merge!(Strategy.page_content(url, homebrew_curl:)) + match_data.merge!( + Strategy.page_content( + url, + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ), + ) match_data[:content] end return match_data if content.blank? diff --git a/Library/Homebrew/livecheck/strategy/sparkle.rb b/Library/Homebrew/livecheck/strategy/sparkle.rb index de16eaec18..cf5c56a3c6 100644 --- a/Library/Homebrew/livecheck/strategy/sparkle.rb +++ b/Library/Homebrew/livecheck/strategy/sparkle.rb @@ -217,13 +217,13 @@ module Homebrew # @return [Hash] sig { params( - url: String, - regex: T.nilable(Regexp), - _unused: T.untyped, - block: T.nilable(Proc), + url: String, + regex: T.nilable(Regexp), + unused: T.untyped, + block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, **_unused, &block) + def self.find_versions(url:, regex: nil, **unused, &block) if regex.present? && block.blank? raise ArgumentError, "#{Utils.demodulize(T.must(name))} only supports a regex when using a `strategy` block" @@ -231,7 +231,12 @@ module Homebrew match_data = { matches: {}, regex:, url: } - match_data.merge!(Strategy.page_content(url)) + match_data.merge!( + Strategy.page_content( + url, + url_options: unused.fetch(:url_options, {}), + ), + ) content = match_data.delete(:content) return match_data if content.blank? diff --git a/Library/Homebrew/livecheck/strategy/xml.rb b/Library/Homebrew/livecheck/strategy/xml.rb index e8b741e9ff..f0dfffa13e 100644 --- a/Library/Homebrew/livecheck/strategy/xml.rb +++ b/Library/Homebrew/livecheck/strategy/xml.rb @@ -142,11 +142,11 @@ module Homebrew regex: T.nilable(Regexp), provided_content: T.nilable(String), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **unused, &block) raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank? match_data = { matches: {}, regex:, url: } @@ -156,7 +156,13 @@ module Homebrew match_data[:cached] = true provided_content else - match_data.merge!(Strategy.page_content(url, homebrew_curl:)) + match_data.merge!( + Strategy.page_content( + url, + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ), + ) match_data[:content] end return match_data if content.blank? diff --git a/Library/Homebrew/livecheck/strategy/yaml.rb b/Library/Homebrew/livecheck/strategy/yaml.rb index 371128a7a9..e3feaf02e5 100644 --- a/Library/Homebrew/livecheck/strategy/yaml.rb +++ b/Library/Homebrew/livecheck/strategy/yaml.rb @@ -102,11 +102,11 @@ module Homebrew regex: T.nilable(Regexp), provided_content: T.nilable(String), homebrew_curl: T::Boolean, - _unused: T.untyped, + unused: T.untyped, block: T.nilable(Proc), ).returns(T::Hash[Symbol, T.untyped]) } - def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **unused, &block) raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank? match_data = { matches: {}, regex:, url: } @@ -116,7 +116,13 @@ module Homebrew match_data[:cached] = true provided_content else - match_data.merge!(Strategy.page_content(url, homebrew_curl:)) + match_data.merge!( + Strategy.page_content( + url, + url_options: unused.fetch(:url_options, {}), + homebrew_curl:, + ), + ) match_data[:content] end return match_data if content.blank? diff --git a/Library/Homebrew/test/livecheck/strategy_spec.rb b/Library/Homebrew/test/livecheck/strategy_spec.rb index 7ed67125bd..95f5222fef 100644 --- a/Library/Homebrew/test/livecheck/strategy_spec.rb +++ b/Library/Homebrew/test/livecheck/strategy_spec.rb @@ -5,6 +5,102 @@ require "livecheck/strategy" RSpec.describe Homebrew::Livecheck::Strategy do subject(:strategy) { described_class } + let(:url) { "https://brew.sh/" } + let(:redirection_url) { "https://brew.sh/redirection" } + + let(:post_hash) do + { + "empty" => "", + "boolean" => "true", + "number" => "1", + "string" => "a + b = c", + } + end + let(:post_hash_symbol_keys) do + { + empty: "", + boolean: "true", + number: "1", + string: "a + b = c", + } + end + let(:form_string) { "empty=&boolean=true&number=1&string=a+%2B+b+%3D+c" } + let(:json_string) { '{"empty":"","boolean":"true","number":"1","string":"a + b = c"}' } + + let(:response_hash) do + response_hash = {} + + response_hash[:ok] = { + status_code: "200", + status_text: "OK", + headers: { + "cache-control" => "max-age=604800", + "content-type" => "text/html; charset=UTF-8", + "date" => "Wed, 1 Jan 2020 01:23:45 GMT", + "expires" => "Wed, 31 Jan 2020 01:23:45 GMT", + "last-modified" => "Thu, 1 Jan 2019 01:23:45 GMT", + "content-length" => "123", + }, + } + + response_hash[:redirection] = { + status_code: "301", + status_text: "Moved Permanently", + headers: { + "cache-control" => "max-age=604800", + "content-type" => "text/html; charset=UTF-8", + "date" => "Wed, 1 Jan 2020 01:23:45 GMT", + "expires" => "Wed, 31 Jan 2020 01:23:45 GMT", + "last-modified" => "Thu, 1 Jan 2019 01:23:45 GMT", + "content-length" => "123", + "location" => redirection_url, + }, + } + + response_hash + end + + let(:body) do + <<~HTML + + + + + Thank you! + + +

Download

+

This download link could have been made publicly available in a reasonable fashion but we appreciate that you jumped through the hoops that we carefully set up!: Example v1.2.3

+

The current legacy version is: Example v0.1.2

+ + + HTML + end + + let(:response_text) do + response_text = {} + + response_text[:ok] = <<~EOS + HTTP/1.1 #{response_hash[:ok][:status_code]} #{response_hash[:ok][:status_text]}\r + Cache-Control: #{response_hash[:ok][:headers]["cache-control"]}\r + Content-Type: #{response_hash[:ok][:headers]["content-type"]}\r + Date: #{response_hash[:ok][:headers]["date"]}\r + Expires: #{response_hash[:ok][:headers]["expires"]}\r + Last-Modified: #{response_hash[:ok][:headers]["last-modified"]}\r + Content-Length: #{response_hash[:ok][:headers]["content-length"]}\r + \r + #{body.rstrip} + EOS + + response_text[:redirection_to_ok] = response_text[:ok].sub( + "HTTP/1.1 #{response_hash[:ok][:status_code]} #{response_hash[:ok][:status_text]}\r", + "HTTP/1.1 #{response_hash[:redirection][:status_code]} #{response_hash[:redirection][:status_text]}\r\n" \ + "Location: #{response_hash[:redirection][:headers]["location"]}\r", + ) + + response_text + end + describe "::from_symbol" do it "returns the Strategy module represented by the Symbol argument" do expect(strategy.from_symbol(:page_match)).to eq(Homebrew::Livecheck::Strategy::PageMatch) @@ -30,6 +126,113 @@ RSpec.describe Homebrew::Livecheck::Strategy do end end + describe "::post_args" do + it "returns an array including `--data` and an encoded form data string" do + expect(strategy.post_args(post_form: post_hash)).to eq(["--data", form_string]) + expect(strategy.post_args(post_form: post_hash_symbol_keys)).to eq(["--data", form_string]) + + # If both `post_form` and `post_json` are present, only `post_form` will + # be used. + expect(strategy.post_args(post_form: post_hash, post_json: post_hash)).to eq(["--data", form_string]) + end + + it "returns an array including `--json` and a JSON string" do + expect(strategy.post_args(post_json: post_hash)).to eq(["--json", json_string]) + expect(strategy.post_args(post_json: post_hash_symbol_keys)).to eq(["--json", json_string]) + end + + it "returns an empty array if `post_form` value is blank" do + expect(strategy.post_args(post_form: {})).to eq([]) + end + + it "returns an empty array if `post_json` value is blank" do + expect(strategy.post_args(post_json: {})).to eq([]) + end + + it "returns an empty array if hash argument doesn't have a `post_form` or `post_json` value" do + expect(strategy.post_args).to eq([]) + end + end + + describe "::page_headers" do + let(:responses) { [response_hash[:ok]] } + + it "returns headers from fetched content" do + allow(strategy).to receive(:curl_headers).and_return({ responses:, body: }) + + expect(strategy.page_headers(url)).to eq([responses.first[:headers]]) + end + + it "handles `post_form` `url` options" do + allow(strategy).to receive(:curl_headers).and_return({ responses:, body: }) + + expect(strategy.page_headers(url, url_options: { post_form: post_hash })) + .to eq([responses.first[:headers]]) + end + + it "returns an empty array if `curl_headers` only raises an `ErrorDuringExecution` error" do + allow(strategy).to receive(:curl_headers).and_raise(ErrorDuringExecution.new([], status: 1)) + + expect(strategy.page_headers(url)).to eq([]) + end + end + + describe "::page_content" do + let(:curl_version) { Version.new("8.7.1") } + let(:success_status) { instance_double(Process::Status, success?: true, exitstatus: 0) } + + it "returns hash including fetched content" do + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status]) + + expect(strategy.page_content(url)).to eq({ content: body }) + end + + it "handles `post_form` `url` option" do + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status]) + + expect(strategy.page_content(url, url_options: { post_form: post_hash })).to eq({ content: body }) + end + + it "handles `post_json` `url` option" do + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status]) + + expect(strategy.page_content(url, url_options: { post_json: post_hash })).to eq({ content: body }) + end + + it "returns error `messages` from `stderr` in the return hash on failure when `stderr` is not `nil`" do + error_message = "curl: (6) Could not resolve host: brew.sh" + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([ + nil, + error_message, + instance_double(Process::Status, success?: false, exitstatus: 6), + ]) + + expect(strategy.page_content(url)).to eq({ messages: [error_message] }) + end + + it "returns default error `messages` in the return hash on failure when `stderr` is `nil`" do + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([ + nil, + nil, + instance_double(Process::Status, success?: false, exitstatus: 1), + ]) + + expect(strategy.page_content(url)).to eq({ messages: ["cURL failed without a detectable error"] }) + end + + it "returns hash including `final_url` if it differs from initial `url`" do + allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version) + allow(strategy).to receive(:curl_output).and_return([response_text[:redirection_to_ok], nil, success_status]) + + expect(strategy.page_content(url)).to eq({ content: body, final_url: redirection_url }) + end + end + describe "::handle_block_return" do it "returns an array of version strings when given a valid value" do expect(strategy.handle_block_return("1.2.3")).to eq(["1.2.3"]) diff --git a/Library/Homebrew/test/livecheck_spec.rb b/Library/Homebrew/test/livecheck_spec.rb index 51f0e1c929..5b75c24ca6 100644 --- a/Library/Homebrew/test/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck_spec.rb @@ -27,6 +27,15 @@ RSpec.describe Livecheck do end let(:livecheck_c) { described_class.new(c) } + let(:post_hash) do + { + "empty" => "", + "boolean" => "true", + "number" => "1", + "string" => "a + b = c", + } + end + describe "#formula" do it "returns nil if not set" do expect(livecheck_f.formula).to be_nil @@ -137,25 +146,38 @@ RSpec.describe Livecheck do expect(livecheck_c.url).to eq(:url) end + it "sets `url_options` when provided" do + post_args = { post_form: post_hash } + livecheck_f.url(url_string, **post_args) + expect(livecheck_f.url_options).to eq(post_args) + end + it "raises an ArgumentError if the argument isn't a valid Symbol" do expect do livecheck_f.url(:not_a_valid_symbol) end.to raise_error ArgumentError end + + it "raises an ArgumentError if both `post_form` and `post_json` arguments are provided" do + expect do + livecheck_f.url(:stable, post_form: post_hash, post_json: post_hash) + end.to raise_error ArgumentError + end end describe "#to_hash" do it "returns a Hash of all instance variables" do expect(livecheck_f.to_hash).to eq( { - "cask" => nil, - "formula" => nil, - "regex" => nil, - "skip" => false, - "skip_msg" => nil, - "strategy" => nil, - "throttle" => nil, - "url" => nil, + "cask" => nil, + "formula" => nil, + "regex" => nil, + "skip" => false, + "skip_msg" => nil, + "strategy" => nil, + "throttle" => nil, + "url" => nil, + "url_options" => nil, }, ) end diff --git a/docs/Brew-Livecheck.md b/docs/Brew-Livecheck.md index a328437a1c..65cd546248 100644 --- a/docs/Brew-Livecheck.md +++ b/docs/Brew-Livecheck.md @@ -112,6 +112,24 @@ end The referenced formula/cask should be in the same tap, as a reference to a formula/cask from another tap will generate an error if the user doesn't already have it tapped. +### `POST` requests + +Some checks require making a `POST` request and that can be accomplished by adding a `post_form` or `post_json` option to a `livecheck` block `url`. + +```ruby +livecheck do + url "https://example.com/download.php", post_form: { + "Name" => "", + "E-mail" => "", + } + regex(/href=.*?example[._-]v?(\d+(?:\.\d+)+)\.t/i) +end +``` + +`post_form` is used for form data and `post_json` is used for JSON data. livecheck will encode the provided hash value to the appropriate format before making the request. + +`POST` support only applies to strategies that use `Strategy::page_headers` or `::page_content` (directly or indirectly), so it does not apply to `ExtractPlist`, `Git`, `GithubLatest`, `GithubReleases`, etc. + ### `strategy` blocks If the upstream version format needs to be manipulated to match the formula/cask format, a `strategy` block can be used instead of a `regex`.