diff --git a/CHANGELOG.md b/CHANGELOG.md index e0dc4a3bc..673813b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased - [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` (module) with `sanitize_shop_domain` and `sanitize!`. - [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) `ShopifyAPI::Auth::TokenExchange.exchange_token` always uses the session token's `dest` claim, instead of the `shop` parameter, that is now deprecated. It will show a deprecation warning and the argument will be removed in the next major version. -- Add support for 2026-07 API version +- [#1454](https://github.com/Shopify/shopify-api-ruby/pull/1454) Add support for 2026-07 API version +- [#1457](https://github.com/Shopify/shopify-api-ruby/pull/1457) Add support for the 2026-10 API version. REST resources for 2026-10 are not bundled yet; the GraphQL Admin API and the REST client are unaffected. +- [#1457](https://github.com/Shopify/shopify-api-ruby/pull/1457) Referencing a REST resource for an API version whose resources aren't bundled now raises `ShopifyAPI::Errors::RestResourceNotLoadedError` (a `NameError` subclass) explaining the version gap, instead of a bare `uninitialized constant`. ## 16.2.0 (2026-04-13) - [#1442](https://github.com/Shopify/shopify-api-ruby/pull/1442) Add support for 2026-04 API version diff --git a/Gemfile.lock b/Gemfile.lock index 27740b31c..482793503 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,6 +8,7 @@ PATH hash_diff httparty jwt + logger oj openssl securerandom diff --git a/lib/shopify_api.rb b/lib/shopify_api.rb index 2414f6dab..ba984b2a9 100644 --- a/lib/shopify_api.rb +++ b/lib/shopify_api.rb @@ -8,6 +8,7 @@ require "securerandom" require "cgi" require "uri" +require "logger" require "openssl" require "httparty" require "zeitwerk" @@ -22,3 +23,71 @@ loader.inflector = ShopifyAPI::Inflector.new(__FILE__) loader.ignore("#{__dir__}/shopify_api/rest/resources") loader.setup + +module ShopifyAPI + REST_RESOURCES_PATH = T.let("#{__dir__}/shopify_api/rest/resources", String) + + class << self + extend T::Sig + + # REST resources are only autoloaded for API versions this gem bundles (see + # Context.load_rest_resources). Without this hook, using a version whose + # resources aren't bundled - a newly released version, or `unstable` - fails + # with a bare "uninitialized constant", which reads like a gem bug instead + # of a version-coverage gap. GraphQL and the REST client are unaffected. + sig { params(name: Symbol).returns(T.untyped) } + def const_missing(name) + if bundled_rest_resource_names.include?(name.to_s) + # Pass `name` through so NameError#name still returns the missing + # constant, as it does for the NameError Ruby would have raised. + raise Errors::RestResourceNotLoadedError.new(rest_resource_not_loaded_message(name), name) + end + + super + end + + private + + # Constant names of every REST resource bundled for any API version, derived + # the same way Zeitwerk derives them so the two cannot drift. + sig { returns(T::Array[String]) } + def bundled_rest_resource_names + @bundled_rest_resource_names ||= T.let( + Dir.glob("#{REST_RESOURCES_PATH}/*/*.rb").map do |path| + Zeitwerk::Inflector.new.camelize(File.basename(path, ".rb"), path) + end.uniq, + T.nilable(T::Array[String]), + ) + end + + sig { returns(T::Array[String]) } + def bundled_rest_resource_versions + @bundled_rest_resource_versions ||= T.let( + Dir.glob("#{REST_RESOURCES_PATH}/*") + .select { |path| File.directory?(path) } + .map { |path| File.basename(path).tr("_", "-") } + .sort, + T.nilable(T::Array[String]), + ) + end + + sig { params(name: Symbol).returns(String) } + def rest_resource_not_loaded_message(name) + api_version = Context.api_version + + reason = if api_version.empty? + "ShopifyAPI::Context has not been set up, so no REST resources have been loaded. " \ + "Call ShopifyAPI::Context.setup before using REST resources." + elsif api_version == "unstable" + "REST resources are not bundled for the \"unstable\" API version. Set api_version to a " \ + "stable version to use REST resources, or use the GraphQL Admin API." + else + "shopify_api #{VERSION} does not bundle REST resources for API version \"#{api_version}\" " \ + "(latest bundled: \"#{bundled_rest_resource_versions.last}\"). Upgrade the gem, set " \ + "api_version to a bundled version, or use the GraphQL Admin API." + end + + "uninitialized constant ShopifyAPI::#{name}. #{reason}" + end + end +end diff --git a/lib/shopify_api/admin_versions.rb b/lib/shopify_api/admin_versions.rb index 8fadab6f2..a5a3d6e43 100644 --- a/lib/shopify_api/admin_versions.rb +++ b/lib/shopify_api/admin_versions.rb @@ -5,6 +5,7 @@ module ShopifyAPI module AdminVersions SUPPORTED_ADMIN_VERSIONS = T.let([ "unstable", + "2026-10", "2026-07", "2026-04", "2026-01", diff --git a/lib/shopify_api/context.rb b/lib/shopify_api/context.rb index e7294ab5d..19a18bfa9 100644 --- a/lib/shopify_api/context.rb +++ b/lib/shopify_api/context.rb @@ -114,7 +114,11 @@ def load_rest_resources(api_version:) unless Dir.exist?(path) unless @notified_missing_resources_folder.key?(api_version) - @logger.warn("Cannot autoload REST resources for API version '#{version_folder_name}', folder is missing") + @logger.warn( + "shopify_api #{ShopifyAPI::VERSION} does not bundle REST resources for API version " \ + "'#{api_version}', so REST resource classes are unavailable. The GraphQL Admin API and " \ + "the REST client are unaffected.", + ) @notified_missing_resources_folder[api_version] = true end diff --git a/lib/shopify_api/errors/rest_resource_not_loaded_error.rb b/lib/shopify_api/errors/rest_resource_not_loaded_error.rb new file mode 100644 index 000000000..86c58e374 --- /dev/null +++ b/lib/shopify_api/errors/rest_resource_not_loaded_error.rb @@ -0,0 +1,14 @@ +# typed: strict +# frozen_string_literal: true + +module ShopifyAPI + module Errors + # Raised when a REST resource class is referenced but this gem version does + # not bundle REST resources for the active API version. + # + # Subclasses NameError so existing `rescue NameError` handling keeps working. + # Construct it with the missing constant name - `new(message, name)` - so + # NameError#name keeps returning it, matching the NameError Ruby would raise. + class RestResourceNotLoadedError < NameError; end + end +end diff --git a/shopify_api.gemspec b/shopify_api.gemspec index aa33ec4dd..babb52bc6 100644 --- a/shopify_api.gemspec +++ b/shopify_api.gemspec @@ -38,6 +38,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency("hash_diff") s.add_runtime_dependency("httparty") s.add_runtime_dependency("jwt") + s.add_runtime_dependency("logger") s.add_runtime_dependency("oj") s.add_runtime_dependency("openssl") s.add_runtime_dependency("securerandom") diff --git a/test/admin_versions_test.rb b/test/admin_versions_test.rb index bb3ee01c1..cf661f2cb 100644 --- a/test/admin_versions_test.rb +++ b/test/admin_versions_test.rb @@ -8,5 +8,20 @@ class AdminVersionsTest < Minitest::Test def test_supported_admin_versions assert_instance_of(Array, ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS) end + + def test_unstable_is_first_and_dated_versions_are_newest_first + versions = ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS + + assert_equal("unstable", versions.first) + + dated = versions.drop(1) + assert_equal(dated.sort.reverse, dated, "dated versions must be listed newest first") + end + + def test_includes_the_next_quarterly_release_candidate + # The next quarterly is supported before its REST resources are bundled so + # GraphQL and REST client users can target it on release day. + assert_includes(ShopifyAPI::AdminVersions::SUPPORTED_ADMIN_VERSIONS, "2026-10") + end end end diff --git a/test/rest_resource_loading_test.rb b/test/rest_resource_loading_test.rb new file mode 100644 index 000000000..102bc6e55 --- /dev/null +++ b/test/rest_resource_loading_test.rb @@ -0,0 +1,131 @@ +# typed: true +# frozen_string_literal: true + +require_relative "test_helper" + +module ShopifyAPITest + class RestResourceLoadingTest < Minitest::Test + BUNDLED_VERSION = "2026-04" + UNBUNDLED_VERSION = "2026-10" + + def setup + setup_context(api_version: BUNDLED_VERSION) + end + + def teardown + setup_context(api_version: BUNDLED_VERSION) + ShopifyAPI::Context.deactivate_session + end + + def test_rest_resources_are_loaded_for_a_bundled_version + assert_operator(ShopifyAPI::Product, :<, ShopifyAPI::Rest::Base) + end + + def test_explains_that_resources_are_not_bundled_for_the_active_version + setup_context(api_version: UNBUNDLED_VERSION) + + error = assert_raises(ShopifyAPI::Errors::RestResourceNotLoadedError) do + ShopifyAPI::Product + end + + assert_kind_of(NameError, error) + assert_includes(error.message, "uninitialized constant ShopifyAPI::Product") + assert_includes(error.message, UNBUNDLED_VERSION) + assert_includes(error.message, "GraphQL Admin API") + end + + def test_preserves_name_error_name_for_consumers_that_inspect_it + setup_context(api_version: UNBUNDLED_VERSION) + + error = assert_raises(ShopifyAPI::Errors::RestResourceNotLoadedError) do + ShopifyAPI::Product + end + + assert_equal(:Product, error.name) + end + + def test_reports_the_missing_resource_in_a_process_that_only_requires_the_gem + lib = File.expand_path("../lib", __dir__) + output = IO.popen( + [RbConfig.ruby, "-I#{lib}", "-e", 'require "shopify_api"; ShopifyAPI::Product'], + err: [:child, :out], + &:read + ) + + refute_includes(output, "uninitialized constant Logger") + assert_includes(output, "RestResourceNotLoadedError") + assert_includes(output, "Context has not been set up") + end + + # Context builds a Logger at class-definition time, and the error message + # above reads Context.api_version - so autoloading Context needs Logger to be + # defined. Whether it happens to be defined transitively depends on the + # resolved dependency set, which is why the entrypoint must require it. The + # subprocess test above cannot catch a regression here on its own: under this + # gem's own bundle, a locked dependency loads logger anyway. + def test_entrypoint_requires_logger_rather_than_relying_on_a_transitive_require + entrypoint = File.read(File.expand_path("../lib/shopify_api.rb", __dir__)) + + assert_includes(entrypoint, %(require "logger")) + end + + def test_names_the_latest_bundled_version_so_the_gap_is_obvious + setup_context(api_version: UNBUNDLED_VERSION) + + error = assert_raises(ShopifyAPI::Errors::RestResourceNotLoadedError) do + ShopifyAPI::Product + end + + assert_includes(error.message, "latest bundled") + end + + def test_explains_that_unstable_never_bundles_resources + setup_context(api_version: "unstable") + + error = assert_raises(ShopifyAPI::Errors::RestResourceNotLoadedError) do + ShopifyAPI::Product + end + + assert_includes(error.message, "unstable") + end + + def test_unknown_constants_still_raise_an_ordinary_name_error + setup_context(api_version: UNBUNDLED_VERSION) + + error = assert_raises(NameError) do + ShopifyAPI.const_missing(:NotAShopifyRestResource) + end + + refute_kind_of(ShopifyAPI::Errors::RestResourceNotLoadedError, error) + end + + def test_warns_when_the_resources_folder_is_missing + # Context only warns once per version per process, so reset the record to + # keep this test independent of execution order. + ShopifyAPI::Context.instance_variable_set(:@notified_missing_resources_folder, {}) + setup_context(api_version: UNBUNDLED_VERSION) + + assert_includes(@logs.string, UNBUNDLED_VERSION) + assert_includes(@logs.string, "REST resource") + assert_includes(@logs.string, "GraphQL Admin API") + end + + private + + def setup_context(api_version:) + @logs = StringIO.new + + ShopifyAPI::Context.setup( + api_key: "key", + api_secret_key: "secret", + api_version: api_version, + scope: [], + is_private: true, + is_embedded: true, + log_level: :warn, + logger: Logger.new(@logs), + private_shop: "privateshop.myshopify.com", + ) + end + end +end