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.
This commit is contained in:
Sam Ford 2025-02-04 10:30:16 -05:00
parent 94e2bdf668
commit b4757af656
No known key found for this signature in database
GPG Key ID: 7AF5CBEE1DD6F76D
14 changed files with 418 additions and 49 deletions

View File

@ -20,6 +20,14 @@ class Livecheck
sig { returns(T.nilable(String)) } sig { returns(T.nilable(String)) }
attr_reader :skip_msg 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 } sig { params(package_or_resource: T.any(Cask::Cask, T.class_of(Formula), Resource)).void }
def initialize(package_or_resource) def initialize(package_or_resource)
@package_or_resource = package_or_resource @package_or_resource = package_or_resource
@ -32,6 +40,7 @@ class Livecheck
@strategy_block = T.let(nil, T.nilable(Proc)) @strategy_block = T.let(nil, T.nilable(Proc))
@throttle = T.let(nil, T.nilable(Integer)) @throttle = T.let(nil, T.nilable(Integer))
@url = T.let(nil, T.any(NilClass, String, Symbol)) @url = T.let(nil, T.any(NilClass, String, Symbol))
@url_options = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))
end end
# Sets the `@referenced_cask_name` instance variable to the provided `String` # Sets the `@referenced_cask_name` instance variable to the provided `String`
@ -134,9 +143,6 @@ class Livecheck
end end
end end
sig { returns(T.nilable(Proc)) }
attr_reader :strategy_block
# Sets the `@throttle` instance variable to the provided `Integer` or returns # Sets the `@throttle` instance variable to the provided `Integer` or returns
# the `@throttle` instance variable when no argument is provided. # the `@throttle` instance variable when no argument is provided.
sig { sig {
@ -158,13 +164,22 @@ class Livecheck
# `@url` instance variable when no argument is provided. The argument can be # `@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 # a `String` (a URL) or a supported `Symbol` corresponding to a URL in the
# formula/cask/resource (e.g. `:stable`, `:homepage`, `:head`, `:url`). # 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 { sig {
params( params(
# URL to check for version information. # 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))) ).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 case url
when nil when nil
@url @url
@ -183,14 +198,15 @@ class Livecheck
sig { returns(T::Hash[String, T.untyped]) } sig { returns(T::Hash[String, T.untyped]) }
def to_hash def to_hash
{ {
"cask" => @referenced_cask_name, "cask" => @referenced_cask_name,
"formula" => @referenced_formula_name, "formula" => @referenced_formula_name,
"regex" => @regex, "regex" => @regex,
"skip" => @skip, "skip" => @skip,
"skip_msg" => @skip_msg, "skip_msg" => @skip_msg,
"strategy" => @strategy, "strategy" => @strategy,
"throttle" => @throttle, "throttle" => @throttle,
"url" => @url, "url" => @url,
"url_options" => @url_options,
} }
end end
end end

View File

@ -614,6 +614,7 @@ module Homebrew
referenced_livecheck = referenced_formula_or_cask&.livecheck referenced_livecheck = referenced_formula_or_cask&.livecheck
livecheck_url = livecheck.url || referenced_livecheck&.url 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_regex = livecheck.regex || referenced_livecheck&.regex
livecheck_strategy = livecheck.strategy || referenced_livecheck&.strategy livecheck_strategy = livecheck.strategy || referenced_livecheck&.strategy
livecheck_strategy_block = livecheck.strategy_block || referenced_livecheck&.strategy_block livecheck_strategy_block = livecheck.strategy_block || referenced_livecheck&.strategy_block
@ -673,6 +674,7 @@ module Homebrew
elsif original_url.present? && original_url != "None" elsif original_url.present? && original_url != "None"
puts "URL: #{original_url}" puts "URL: #{original_url}"
end end
puts "URL Options: #{livecheck_url_options}" if livecheck_url_options.present?
puts "URL (processed): #{url}" if url != original_url puts "URL (processed): #{url}" if url != original_url
if strategies.present? && verbose if strategies.present? && verbose
puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}" puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}"
@ -701,6 +703,7 @@ module Homebrew
strategy_args = { strategy_args = {
regex: livecheck_regex, regex: livecheck_regex,
url_options: livecheck_url_options,
homebrew_curl:, homebrew_curl:,
} }
# TODO: Set `cask`/`url` args based on the presence of the keyword arg # 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] version_info[:meta][:url][:strategy] = strategy_data[:url]
end end
version_info[:meta][:url][:final] = strategy_data[:final_url] if strategy_data[:final_url] 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? version_info[:meta][:url][:homebrew_curl] = homebrew_curl if homebrew_curl.present?
end end
version_info[:meta][:strategy] = strategy_name if strategy.present? version_info[:meta][:strategy] = strategy_name if strategy.present?
@ -856,6 +860,7 @@ module Homebrew
livecheck = resource.livecheck livecheck = resource.livecheck
livecheck_reference = livecheck.formula livecheck_reference = livecheck.formula
livecheck_url = livecheck.url livecheck_url = livecheck.url
livecheck_url_options = livecheck.url_options
livecheck_regex = livecheck.regex livecheck_regex = livecheck.regex
livecheck_strategy = livecheck.strategy livecheck_strategy = livecheck.strategy
livecheck_strategy_block = livecheck.strategy_block livecheck_strategy_block = livecheck.strategy_block
@ -893,6 +898,7 @@ module Homebrew
elsif original_url.present? && original_url != "None" elsif original_url.present? && original_url != "None"
puts "URL: #{original_url}" puts "URL: #{original_url}"
end end
puts "URL Options: #{livecheck_url_options}" if livecheck_url_options.present?
puts "URL (processed): #{url}" if url != original_url puts "URL (processed): #{url}" if url != original_url
if strategies.present? && verbose if strategies.present? && verbose
puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}" puts "Strategies: #{strategies.map { |s| livecheck_strategy_names[s] }.join(", ")}"
@ -923,6 +929,7 @@ module Homebrew
strategy_args = { strategy_args = {
url:, url:,
regex: livecheck_regex, regex: livecheck_regex,
url_options: livecheck_url_options,
homebrew_curl: false, homebrew_curl: false,
}.compact }.compact
@ -1012,6 +1019,7 @@ module Homebrew
resource_version_info[:meta][:url][:strategy] = strategy_data[:url] resource_version_info[:meta][:url][:strategy] = strategy_data[:url]
end end
resource_version_info[:meta][:url][:final] = strategy_data[:final_url] if strategy_data&.dig(:final_url) 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 end
resource_version_info[:meta][:strategy] = strategy_name if strategy.present? resource_version_info[:meta][:strategy] = strategy_name if strategy.present?
if strategies.present? if strategies.present?

View File

@ -166,20 +166,59 @@ module Homebrew
end end
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. # Collects HTTP response headers, starting with the provided URL.
# Redirections will be followed and all the response headers are # Redirections will be followed and all the response headers are
# collected into an array of hashes. # collected into an array of hashes.
# #
# @param url [String] the URL to fetch # @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 # @param homebrew_curl [Boolean] whether to use brewed curl with the URL
# @return [Array] # @return [Array]
sig { params(url: String, homebrew_curl: T::Boolean).returns(T::Array[T::Hash[String, String]]) } sig {
def self.page_headers(url, homebrew_curl: false) 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 = [] 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| [:default, :browser].each do |user_agent|
begin begin
parsed_output = curl_headers( parsed_output = curl_headers(
*curl_post_args,
"--max-redirs", "--max-redirs",
MAX_REDIRECTIONS.to_s, MAX_REDIRECTIONS.to_s,
url, url,
@ -205,13 +244,28 @@ module Homebrew
# array with the error message instead. # array with the error message instead.
# #
# @param url [String] the URL of the content to check # @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 # @param homebrew_curl [Boolean] whether to use brewed curl with the URL
# @return [Hash] # @return [Hash]
sig { params(url: String, homebrew_curl: T::Boolean).returns(T::Hash[Symbol, T.untyped]) } sig {
def self.page_content(url, homebrew_curl: false) 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)) stderr = T.let(nil, T.nilable(String))
[:default, :browser].each do |user_agent| [:default, :browser].each do |user_agent|
stdout, stderr, status = curl_output( stdout, stderr, status = curl_output(
*curl_post_args,
*PAGE_CONTENT_CURL_ARGS, url, *PAGE_CONTENT_CURL_ARGS, url,
**DEFAULT_CURL_OPTIONS, **DEFAULT_CURL_OPTIONS,
use_homebrew_curl: homebrew_curl || !curl_supports_fail_with_body?, use_homebrew_curl: homebrew_curl || !curl_supports_fail_with_body?,

View File

@ -0,0 +1,9 @@
# typed: strict
module Homebrew
module Livecheck
module Strategy
include Kernel
end
end
end

View File

@ -81,11 +81,11 @@ module Homebrew
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
provided_content: T.nilable(String), provided_content: T.nilable(String),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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 = { matches: {}, regex:, url: }
match_data[:cached] = true if provided_content.is_a?(String) match_data[:cached] = true if provided_content.is_a?(String)
@ -97,7 +97,13 @@ module Homebrew
content = if provided_content content = if provided_content
provided_content provided_content
else 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] match_data[:content]
end end
return match_data unless content return match_data unless content

View File

@ -74,14 +74,18 @@ module Homebrew
url: String, url: String,
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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: } 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 # Merge the headers from all responses into one hash
merged_headers = headers.reduce(&:merge) merged_headers = headers.reduce(&:merge)

View File

@ -102,11 +102,11 @@ module Homebrew
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
provided_content: T.nilable(String), provided_content: T.nilable(String),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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? raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank?
match_data = { matches: {}, regex:, url: } match_data = { matches: {}, regex:, url: }
@ -116,7 +116,13 @@ module Homebrew
match_data[:cached] = true match_data[:cached] = true
provided_content provided_content
else 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] match_data[:content]
end end
return match_data if content.blank? return match_data if content.blank?

View File

@ -85,11 +85,11 @@ module Homebrew
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
provided_content: T.nilable(String), provided_content: T.nilable(String),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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? if regex.blank? && block.blank?
raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a regex or `strategy` block" raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a regex or `strategy` block"
end end
@ -101,7 +101,13 @@ module Homebrew
match_data[:cached] = true match_data[:cached] = true
provided_content provided_content
else 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] match_data[:content]
end end
return match_data if content.blank? return match_data if content.blank?

View File

@ -217,13 +217,13 @@ module Homebrew
# @return [Hash] # @return [Hash]
sig { sig {
params( params(
url: String, url: String,
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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? if regex.present? && block.blank?
raise ArgumentError, raise ArgumentError,
"#{Utils.demodulize(T.must(name))} only supports a regex when using a `strategy` block" "#{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 = { 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) content = match_data.delete(:content)
return match_data if content.blank? return match_data if content.blank?

View File

@ -142,11 +142,11 @@ module Homebrew
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
provided_content: T.nilable(String), provided_content: T.nilable(String),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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? raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank?
match_data = { matches: {}, regex:, url: } match_data = { matches: {}, regex:, url: }
@ -156,7 +156,13 @@ module Homebrew
match_data[:cached] = true match_data[:cached] = true
provided_content provided_content
else 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] match_data[:content]
end end
return match_data if content.blank? return match_data if content.blank?

View File

@ -102,11 +102,11 @@ module Homebrew
regex: T.nilable(Regexp), regex: T.nilable(Regexp),
provided_content: T.nilable(String), provided_content: T.nilable(String),
homebrew_curl: T::Boolean, homebrew_curl: T::Boolean,
_unused: T.untyped, unused: T.untyped,
block: T.nilable(Proc), block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.untyped]) ).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? raise ArgumentError, "#{Utils.demodulize(T.must(name))} requires a `strategy` block" if block.blank?
match_data = { matches: {}, regex:, url: } match_data = { matches: {}, regex:, url: }
@ -116,7 +116,13 @@ module Homebrew
match_data[:cached] = true match_data[:cached] = true
provided_content provided_content
else 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] match_data[:content]
end end
return match_data if content.blank? return match_data if content.blank?

View File

@ -5,6 +5,102 @@ require "livecheck/strategy"
RSpec.describe Homebrew::Livecheck::Strategy do RSpec.describe Homebrew::Livecheck::Strategy do
subject(:strategy) { described_class } 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
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thank you!</title>
</head>
<body>
<h1>Download</h1>
<p>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!: <a href="https://brew.sh/example-1.2.3.tar.gz">Example v1.2.3</a></p>
<p>The current legacy version is: <a href="https://brew.sh/example-0.1.2.tar.gz">Example v0.1.2</a></p>
</body>
</html>
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 describe "::from_symbol" do
it "returns the Strategy module represented by the Symbol argument" do it "returns the Strategy module represented by the Symbol argument" do
expect(strategy.from_symbol(:page_match)).to eq(Homebrew::Livecheck::Strategy::PageMatch) expect(strategy.from_symbol(:page_match)).to eq(Homebrew::Livecheck::Strategy::PageMatch)
@ -30,6 +126,113 @@ RSpec.describe Homebrew::Livecheck::Strategy do
end end
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 describe "::handle_block_return" do
it "returns an array of version strings when given a valid value" 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"]) expect(strategy.handle_block_return("1.2.3")).to eq(["1.2.3"])

View File

@ -27,6 +27,15 @@ RSpec.describe Livecheck do
end end
let(:livecheck_c) { described_class.new(c) } let(:livecheck_c) { described_class.new(c) }
let(:post_hash) do
{
"empty" => "",
"boolean" => "true",
"number" => "1",
"string" => "a + b = c",
}
end
describe "#formula" do describe "#formula" do
it "returns nil if not set" do it "returns nil if not set" do
expect(livecheck_f.formula).to be_nil expect(livecheck_f.formula).to be_nil
@ -137,25 +146,38 @@ RSpec.describe Livecheck do
expect(livecheck_c.url).to eq(:url) expect(livecheck_c.url).to eq(:url)
end 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 it "raises an ArgumentError if the argument isn't a valid Symbol" do
expect do expect do
livecheck_f.url(:not_a_valid_symbol) livecheck_f.url(:not_a_valid_symbol)
end.to raise_error ArgumentError end.to raise_error ArgumentError
end 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 end
describe "#to_hash" do describe "#to_hash" do
it "returns a Hash of all instance variables" do it "returns a Hash of all instance variables" do
expect(livecheck_f.to_hash).to eq( expect(livecheck_f.to_hash).to eq(
{ {
"cask" => nil, "cask" => nil,
"formula" => nil, "formula" => nil,
"regex" => nil, "regex" => nil,
"skip" => false, "skip" => false,
"skip_msg" => nil, "skip_msg" => nil,
"strategy" => nil, "strategy" => nil,
"throttle" => nil, "throttle" => nil,
"url" => nil, "url" => nil,
"url_options" => nil,
}, },
) )
end end

View File

@ -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. 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 ### `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`. 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`.