Sam Ford b4757af656
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.
2025-02-07 08:53:47 -05:00

253 lines
8.2 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "bundle_version"
module Homebrew
module Livecheck
module Strategy
# The {Sparkle} strategy fetches content at a URL and parses it as a
# Sparkle appcast in XML format.
#
# This strategy is not applied automatically and it's necessary to use
# `strategy :sparkle` in a `livecheck` block to apply it.
class Sparkle
# A priority of zero causes livecheck to skip the strategy. We do this
# for {Sparkle} so we can selectively apply it when appropriate.
PRIORITY = 0
# The `Regexp` used to determine if the strategy applies to the URL.
URL_MATCH_REGEX = %r{^https?://}i
# Common `os` values used in appcasts to refer to macOS.
APPCAST_MACOS_STRINGS = T.let(["macos", "osx"].freeze, T::Array[String])
# Whether the strategy can be applied to the provided URL.
#
# @param url [String] the URL to match against
# @return [Boolean]
sig { params(url: String).returns(T::Boolean) }
def self.match?(url)
URL_MATCH_REGEX.match?(url)
end
Item = Struct.new(
# @api public
:title,
# @api public
:link,
# @api public
:channel,
# @api public
:release_notes_link,
# @api public
:pub_date,
# @api public
:os,
# @api public
:url,
# @api private
:bundle_version,
# @api public
:minimum_system_version,
keyword_init: true,
) do
extend Forwardable
# @!attribute [r] version
# @api public
delegate version: :bundle_version
# @!attribute [r] short_version
# @api public
delegate short_version: :bundle_version
# @!attribute [r] nice_version
# @api public
delegate nice_version: :bundle_version
end
# Identifies version information from a Sparkle appcast.
#
# @param content [String] the text of the Sparkle appcast
# @return [Item, nil]
sig { params(content: String).returns(T::Array[Item]) }
def self.items_from_content(content)
require "rexml/document"
xml = Xml.parse_xml(content)
return [] if xml.blank?
# Remove prefixes, so we can reliably identify elements and attributes
xml.root&.each_recursive do |node|
node.prefix = ""
node.attributes.each_attribute do |attribute|
attribute.prefix = ""
end
end
xml.get_elements("//rss//channel//item").filter_map do |item|
enclosure = item.elements["enclosure"]
if enclosure
url = enclosure["url"].presence
short_version = enclosure["shortVersionString"].presence
version = enclosure["version"].presence
os = enclosure["os"].presence
end
title = Xml.element_text(item, "title")
link = Xml.element_text(item, "link")
url ||= link
channel = Xml.element_text(item, "channel")
release_notes_link = Xml.element_text(item, "releaseNotesLink")
short_version ||= Xml.element_text(item, "shortVersionString")
version ||= Xml.element_text(item, "version")
minimum_system_version_text =
Xml.element_text(item, "minimumSystemVersion")&.gsub(/\A\D+|\D+\z/, "")
if minimum_system_version_text.present?
minimum_system_version = begin
MacOSVersion.new(minimum_system_version_text)
rescue MacOSVersion::Error
nil
end
end
pub_date = Xml.element_text(item, "pubDate")&.then do |date_string|
Time.parse(date_string)
rescue ArgumentError
# Omit unparsable strings (e.g. non-English dates)
nil
end
if (match = title&.match(/(\d+(?:\.\d+)*)\s*(\([^)]+\))?\Z/))
short_version ||= match[1]
version ||= match[2]
end
bundle_version = BundleVersion.new(short_version, version) if short_version || version
data = {
title:,
link:,
channel:,
release_notes_link:,
pub_date:,
os:,
url:,
bundle_version:,
minimum_system_version:,
}.compact
next if data.empty?
# Set a default `pub_date` (for sorting) if one isn't provided
data[:pub_date] ||= Time.new(0)
Item.new(**data)
end
end
# Filters out items that aren't suitable for Homebrew.
#
# @param items [Array] appcast items
# @return [Array]
sig { params(items: T::Array[Item]).returns(T::Array[Item]) }
def self.filter_items(items)
items.select do |item|
# Omit items with an explicit `os` value that isn't macOS
next false if item.os && APPCAST_MACOS_STRINGS.none?(item.os)
# Omit items for prerelease macOS versions
next false if item.minimum_system_version&.strip_patch&.prerelease?
true
end.compact
end
# Sorts items from newest to oldest.
#
# @param items [Array] appcast items
# @return [Array]
sig { params(items: T::Array[Item]).returns(T::Array[Item]) }
def self.sort_items(items)
items.sort_by { |item| [item.pub_date, item.bundle_version] }
.reverse
end
# Uses `#items_from_content` to identify versions from the Sparkle
# appcast content or, if a block is provided, passes the content to
# the block to handle matching.
#
# @param content [String] the content to check
# @param regex [Regexp, nil] a regex for use in a strategy block
# @return [Array]
sig {
params(
content: String,
regex: T.nilable(Regexp),
block: T.nilable(Proc),
).returns(T::Array[String])
}
def self.versions_from_content(content, regex = nil, &block)
items = sort_items(filter_items(items_from_content(content)))
return [] if items.blank?
item = items.first
if block
block_return_value = case block.parameters[0]
when [:opt, :item], [:rest], [:req]
regex.present? ? yield(item, regex) : yield(item)
when [:opt, :items]
regex.present? ? yield(items, regex) : yield(items)
else
raise "First argument of Sparkle `strategy` block must be `item` or `items`"
end
return Strategy.handle_block_return(block_return_value)
end
version = T.must(item).bundle_version&.nice_version
version.present? ? [version] : []
end
# Checks the content at the URL for new versions.
#
# @param url [String] the URL of the content to check
# @param regex [Regexp, nil] a regex for use in a strategy block
# @return [Hash]
sig {
params(
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)
if regex.present? && block.blank?
raise ArgumentError,
"#{Utils.demodulize(T.must(name))} only supports a regex when using a `strategy` block"
end
match_data = { matches: {}, regex:, 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?
versions_from_content(content, regex, &block).each do |version_text|
match_data[:matches][version_text] = Version.new(version_text)
end
match_data
end
end
end
end
end