diff --git a/Library/Homebrew/livecheck/strategy.rb b/Library/Homebrew/livecheck/strategy.rb index baa2eea270..61e7e06ed3 100644 --- a/Library/Homebrew/livecheck/strategy.rb +++ b/Library/Homebrew/livecheck/strategy.rb @@ -153,9 +153,13 @@ module Homebrew def from_url(url, livecheck_strategy: nil, url_provided: false, regex_provided: false, block_provided: false) usable_strategies = strategies.values.select do |strategy| if strategy == PageMatch - # Only treat the `PageMatch` strategy as usable if a regex is - # present in the `livecheck` block + # Only treat the strategy as usable if the `livecheck` block + # contains a regex and/or `strategy` block next if !regex_provided && !block_provided + elsif strategy == Json + # Only treat the strategy as usable if the `livecheck` block + # contains a `strategy` block + next unless block_provided elsif strategy.const_defined?(:PRIORITY) && !strategy::PRIORITY.positive? && from_symbol(livecheck_strategy) != strategy @@ -273,6 +277,7 @@ require_relative "strategy/gnome" require_relative "strategy/gnu" require_relative "strategy/hackage" require_relative "strategy/header_match" +require_relative "strategy/json" require_relative "strategy/launchpad" require_relative "strategy/npm" require_relative "strategy/page_match" diff --git a/Library/Homebrew/livecheck/strategy/json.rb b/Library/Homebrew/livecheck/strategy/json.rb new file mode 100644 index 0000000000..a6353ddd58 --- /dev/null +++ b/Library/Homebrew/livecheck/strategy/json.rb @@ -0,0 +1,126 @@ +# typed: true +# frozen_string_literal: true + +module Homebrew + module Livecheck + module Strategy + # The {Json} strategy fetches content at a URL, parses it as JSON, and + # provides the parsed data to a `strategy` block. If a regex is present + # in the `livecheck` block, it should be passed as the second argument to + # the `strategy` block. + # + # This is a generic strategy that doesn't contain any logic for finding + # versions, as the structure of JSON data varies. Instead, a `strategy` + # block must be used to extract version information from the JSON data. + # + # This strategy is not applied automatically and it is necessary to use + # `strategy :json` in a `livecheck` block (in conjunction with a + # `strategy` block) to use it. + # + # This strategy's {find_versions} method can be used in other strategies + # that work with JSON content, so it should only be necessary to write + # the version-finding logic that works with the parsed JSON data. + # + # @api public + class Json + extend T::Sig + + NICE_NAME = "JSON" + + # A priority of zero causes livecheck to skip the strategy. We do this + # for {Json} so we can selectively apply it only when a strategy block + # is provided in a `livecheck` block. + PRIORITY = 0 + + # The `Regexp` used to determine if the strategy applies to the URL. + URL_MATCH_REGEX = %r{^https?://}i.freeze + + # Whether the strategy can be applied to the provided URL. + # {Json} will technically match any HTTP URL but is only usable with + # a `livecheck` block containing a `strategy` block. + # + # @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 + + # Parses JSON text and identifies versions using a `strategy` block. + # If a regex is provided, it will be passed as the second argument to + # the `strategy` block (after the parsed JSON data). + # @param content [String] the JSON text to parse and check + # @param regex [Regexp, nil] a regex used for matching versions in the + # content + # @return [Array] + sig { + params( + content: String, + regex: T.nilable(Regexp), + block: T.untyped, + ).returns(T::Array[String]) + } + def self.versions_from_content(content, regex = nil, &block) + return [] if content.blank? || block.blank? + + require "json" + json = begin + JSON.parse(content) + rescue JSON::ParserError + raise "Content could not be parsed as JSON." + end + + block_return_value = if regex.present? + yield(json, regex) + elsif block.arity == 2 + raise "Two arguments found in `strategy` block but no regex provided." + else + yield(json) + end + Strategy.handle_block_return(block_return_value) + end + + # Checks the JSON content at the URL for versions, using the provided + # `strategy` block to extract version information. + # + # @param url [String] the URL of the content to check + # @param regex [Regexp, nil] a regex used for matching versions + # @param provided_content [String, nil] page content to use in place of + # fetching via `Strategy#page_content` + # @param homebrew_curl [Boolean] whether to use brewed curl with the URL + # @return [Hash] + sig { + params( + url: String, + regex: T.nilable(Regexp), + provided_content: T.nilable(String), + homebrew_curl: T::Boolean, + _unused: T.nilable(T::Hash[Symbol, T.untyped]), + block: T.untyped, + ).returns(T::Hash[Symbol, T.untyped]) + } + def self.find_versions(url:, regex: nil, provided_content: nil, homebrew_curl: false, **_unused, &block) + raise ArgumentError, "#{T.must(name).demodulize} requires a `strategy` block" if block.blank? + + match_data = { matches: {}, regex: regex, url: url } + return match_data if url.blank? || block.blank? + + content = if provided_content.is_a?(String) + match_data[:cached] = true + provided_content + else + match_data.merge!(Strategy.page_content(url, homebrew_curl: homebrew_curl)) + match_data[:content] + end + return match_data if content.blank? + + versions_from_content(content, regex, &block).each do |match_text| + match_data[:matches][match_text] = Version.new(match_text) + end + + match_data + end + end + end + end +end diff --git a/Library/Homebrew/test/livecheck/strategy/json_spec.rb b/Library/Homebrew/test/livecheck/strategy/json_spec.rb new file mode 100644 index 0000000000..79f71925e1 --- /dev/null +++ b/Library/Homebrew/test/livecheck/strategy/json_spec.rb @@ -0,0 +1,149 @@ +# typed: false +# frozen_string_literal: true + +require "livecheck/strategy" + +describe Homebrew::Livecheck::Strategy::Json do + subject(:json) { described_class } + + let(:http_url) { "https://brew.sh/blog/" } + let(:non_http_url) { "ftp://brew.sh/" } + + let(:regex) { /^v?(\d+(?:\.\d+)+)$/i } + + let(:simple_content) { '{"version":"1.2.3"}' } + let(:content) { + <<~EOS + { + "versions": [ + { "version": "1.1.2" }, + { "version": "1.1.2b" }, + { "version": "1.1.2a" }, + { "version": "1.1.1" }, + { "version": "1.1.0" }, + { "version": "1.1.0-rc3" }, + { "version": "1.1.0-rc2" }, + { "version": "1.1.0-rc1" }, + { "version": "1.0.x-last" }, + { "version": "1.0.3" }, + { "version": "1.0.3-rc3" }, + { "version": "1.0.3-rc2" }, + { "version": "1.0.3-rc1" }, + { "version": "1.0.2" }, + { "version": "1.0.2-rc1" }, + { "version": "1.0.1" }, + { "version": "1.0.1-rc1" }, + { "version": "1.0.0" }, + { "version": "1.0.0-rc1" }, + { "other": "version is omitted from this object for testing" } + ] + } + EOS + } + + let(:simple_content_matches) { ["1.2.3"] } + let(:content_matches) { ["1.1.2", "1.1.1", "1.1.0", "1.0.3", "1.0.2", "1.0.1", "1.0.0"] } + + let(:find_versions_return_hash) { + { + matches: { + "1.1.2" => Version.new("1.1.2"), + "1.1.1" => Version.new("1.1.1"), + "1.1.0" => Version.new("1.1.0"), + "1.0.3" => Version.new("1.0.3"), + "1.0.2" => Version.new("1.0.2"), + "1.0.1" => Version.new("1.0.1"), + "1.0.0" => Version.new("1.0.0"), + }, + regex: regex, + url: http_url, + } + } + + let(:find_versions_cached_return_hash) { + find_versions_return_hash.merge({ cached: true }) + } + + describe "::match?" do + it "returns true for an HTTP URL" do + expect(json.match?(http_url)).to be true + end + + it "returns false for a non-HTTP URL" do + expect(json.match?(non_http_url)).to be false + end + end + + describe "::versions_from_content" do + it "returns an empty array when given a block but content is blank" do + expect(json.versions_from_content("", regex) { "1.2.3" }).to eq([]) + end + + it "errors if provided content is not valid JSON" do + expect { json.versions_from_content("not valid JSON") { [] } } + .to raise_error(RuntimeError, "Content could not be parsed as JSON.") + end + + it "returns an array of version strings when given content and a block" do + # Returning a string from block + expect(json.versions_from_content(simple_content) { |json| json["version"] }).to eq(simple_content_matches) + expect(json.versions_from_content(simple_content, regex) do |json| + json["version"][regex, 1] + end).to eq(simple_content_matches) + + # Returning an array of strings from block + expect(json.versions_from_content(content, regex) do |json, regex| + json["versions"].select { |item| item["version"]&.match?(regex) } + .map { |item| item["version"][regex, 1] } + end).to eq(content_matches) + end + + it "allows a nil return from a block" do + expect(json.versions_from_content(content, regex) { next }).to eq([]) + end + + it "errors if a block uses two arguments but a regex is not given" do + expect { json.versions_from_content(simple_content) { |json, regex| json["version"][regex, 1] } } + .to raise_error("Two arguments found in `strategy` block but no regex provided.") + end + + it "errors on an invalid return type from a block" do + expect { json.versions_from_content(content, regex) { 123 } } + .to raise_error(TypeError, Homebrew::Livecheck::Strategy::INVALID_BLOCK_RETURN_VALUE_MSG) + end + end + + describe "::find_versions?" do + it "finds versions in provided_content using a block" do + expect(json.find_versions(url: http_url, regex: regex, provided_content: content) do |json, regex| + json["versions"].select { |item| item["version"]&.match?(regex) } + .map { |item| item["version"][regex, 1] } + end).to eq(find_versions_cached_return_hash) + + # NOTE: A regex should be provided using the `#regex` method in a + # `livecheck` block but we're using a regex literal in the `strategy` + # block here simply to ensure this method works as expected when a + # regex isn't provided. + expect(json.find_versions(url: http_url, provided_content: content) do |json| + regex = /^v?(\d+(?:\.\d+)+)$/i.freeze + json["versions"].select { |item| item["version"]&.match?(regex) } + .map { |item| item["version"][regex, 1] } + end).to eq(find_versions_cached_return_hash.merge({ regex: nil })) + end + + it "errors if a block is not provided" do + expect { json.find_versions(url: http_url, provided_content: content) } + .to raise_error(ArgumentError, "Json requires a `strategy` block") + end + + it "returns default match_data when url is blank" do + expect(json.find_versions(url: "") { "1.2.3" }) + .to eq({ matches: {}, regex: nil, url: "" }) + end + + it "returns default match_data when content is blank" do + expect(json.find_versions(url: http_url, provided_content: "") { "1.2.3" }) + .to eq({ matches: {}, regex: nil, url: http_url, cached: true }) + end + end +end diff --git a/docs/Brew-Livecheck.md b/docs/Brew-Livecheck.md index 63856f5d79..28919e37db 100644 --- a/docs/Brew-Livecheck.md +++ b/docs/Brew-Livecheck.md @@ -141,6 +141,21 @@ livecheck do end ``` +#### `Json` `strategy` block + +A `strategy` block for `Json` receives parsed JSON data and, if provided, a regex. For example, if we have an object containing an array of objects with a `version` string, we can select only the members that match the regex and isolate the relevant version text as follows: + +```ruby +livecheck do + url "https://www.example.com/example.json" + regex(/^v?(\d+(?:\.\d+)+)$/i) + strategy :json do |json, regex| + json["versions"].select { |item| item["version"]&.match?(regex) } + .map { |item| item["version"][regex, 1] } + end +end +``` + #### `Sparkle` `strategy` block A `strategy` block for `Sparkle` receives an `item` which has methods for the `short_version`, `version`, `url` and `title`.