livecheck: Add Json strategy
This adds a generic `Json` strategy to livecheck that requires a `strategy` block to operate. This is primarily intended as a replacement for existing `strategy` blocks in formulae/casks that use `JSON#parse`, as it allows us to internalize/standardize that boilerplate while improving error-handling. Additionally, future strategies that parse JSON data can use the `Json#find_versions` method instead of having to reinvent the wheel (similar to how we currently have a number of strategies that leverage `PageMatch#find_versions`).
This commit is contained in:
parent
7d7e494bc2
commit
7735036c56
@ -153,9 +153,13 @@ module Homebrew
|
|||||||
def from_url(url, livecheck_strategy: nil, url_provided: false, regex_provided: false, block_provided: false)
|
def from_url(url, livecheck_strategy: nil, url_provided: false, regex_provided: false, block_provided: false)
|
||||||
usable_strategies = strategies.values.select do |strategy|
|
usable_strategies = strategies.values.select do |strategy|
|
||||||
if strategy == PageMatch
|
if strategy == PageMatch
|
||||||
# Only treat the `PageMatch` strategy as usable if a regex is
|
# Only treat the strategy as usable if the `livecheck` block
|
||||||
# present in the `livecheck` block
|
# contains a regex and/or `strategy` block
|
||||||
next if !regex_provided && !block_provided
|
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) &&
|
elsif strategy.const_defined?(:PRIORITY) &&
|
||||||
!strategy::PRIORITY.positive? &&
|
!strategy::PRIORITY.positive? &&
|
||||||
from_symbol(livecheck_strategy) != strategy
|
from_symbol(livecheck_strategy) != strategy
|
||||||
@ -273,6 +277,7 @@ require_relative "strategy/gnome"
|
|||||||
require_relative "strategy/gnu"
|
require_relative "strategy/gnu"
|
||||||
require_relative "strategy/hackage"
|
require_relative "strategy/hackage"
|
||||||
require_relative "strategy/header_match"
|
require_relative "strategy/header_match"
|
||||||
|
require_relative "strategy/json"
|
||||||
require_relative "strategy/launchpad"
|
require_relative "strategy/launchpad"
|
||||||
require_relative "strategy/npm"
|
require_relative "strategy/npm"
|
||||||
require_relative "strategy/page_match"
|
require_relative "strategy/page_match"
|
||||||
|
126
Library/Homebrew/livecheck/strategy/json.rb
Normal file
126
Library/Homebrew/livecheck/strategy/json.rb
Normal file
@ -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
|
149
Library/Homebrew/test/livecheck/strategy/json_spec.rb
Normal file
149
Library/Homebrew/test/livecheck/strategy/json_spec.rb
Normal file
@ -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
|
@ -141,6 +141,21 @@ livecheck do
|
|||||||
end
|
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
|
#### `Sparkle` `strategy` block
|
||||||
|
|
||||||
A `strategy` block for `Sparkle` receives an `item` which has methods for the `short_version`, `version`, `url` and `title`.
|
A `strategy` block for `Sparkle` receives an `item` which has methods for the `short_version`, `version`, `url` and `title`.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user