From b4757af656fb38806dc5defe5c8004f941e429ae Mon Sep 17 00:00:00 2001 From: Sam Ford <1584702+samford@users.noreply.github.com> Date: Tue, 4 Feb 2025 10:30:16 -0500 Subject: [PATCH] livecheck: Add support for POST requests livecheck currently doesn't support `POST` requests but it wasn't entirely clear how best to handle that. I initially approached it as a `Post` strategy but unfortunately that would have required us to handle response body parsing (e.g., JSON, XML, etc.) in some fashion. We could borrow some of the logic from related strategies but we would still be stuck having to update `Post` whenever we add a strategy for a new format. Instead, this implements `POST` support by borrowing ideas from the `using: :post` and `data` `url` options found in formulae. This uses a `post_form` option to handle form data and `post_json` to handle JSON data, encoding the hash argument for each into the appropriate format. The presence of either option means that curl will use a `POST` request. With this approach, we can make a `POST` request using any strategy that calls `Strategy::page_headers` or `::page_content` (directly or indirectly) and everything else works the same as usual. The only change needed in related strategies was to pass the options through to the `Strategy` methods. For example, if we need to parse a JSON response from a `POST` request, we add a `post_data` or `post_json` hash to the `livecheck` block `url` and use `strategy :json` with a `strategy` block. This leans on existing patterns that we're already familiar with and shouldn't require any notable maintenance burden when adding new strategies, so it seems like a better approach than a `Post` strategy. --- Library/Homebrew/livecheck.rb | 42 ++-- Library/Homebrew/livecheck/livecheck.rb | 8 + Library/Homebrew/livecheck/strategy.rb | 62 +++++- Library/Homebrew/livecheck/strategy.rbi | 9 + Library/Homebrew/livecheck/strategy/crate.rb | 12 +- .../livecheck/strategy/header_match.rb | 10 +- Library/Homebrew/livecheck/strategy/json.rb | 12 +- .../Homebrew/livecheck/strategy/page_match.rb | 12 +- .../Homebrew/livecheck/strategy/sparkle.rb | 17 +- Library/Homebrew/livecheck/strategy/xml.rb | 12 +- Library/Homebrew/livecheck/strategy/yaml.rb | 12 +- .../Homebrew/test/livecheck/strategy_spec.rb | 203 ++++++++++++++++++ Library/Homebrew/test/livecheck_spec.rb | 38 +++- docs/Brew-Livecheck.md | 18 ++ 14 files changed, 418 insertions(+), 49 deletions(-) create mode 100644 Library/Homebrew/livecheck/strategy.rbi 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`.