From 3efcf6b11d8f03cf842b844e09e9c0964d7af95d Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Mon, 27 Jul 2026 13:55:04 -0500 Subject: [PATCH 1/3] Support 2026-10 and explain missing REST resources clearly Adds 2026-10 to SUPPORTED_ADMIN_VERSIONS so GraphQL and REST client users can target it immediately. REST resources for a version are a separate concern: when they aren't bundled, Context already warns and continues rather than raising. That gap previously surfaced to REST resource users as a bare "uninitialized constant ShopifyAPI::Product", which reads like a gem bug. A const_missing hook on ShopifyAPI now recognises known REST resource names and raises RestResourceNotLoadedError (a NameError subclass, so existing rescues keep working) explaining that resources aren't bundled for the active version, naming the latest bundled version, and pointing at the alternatives. --- lib/shopify_api.rb | 66 +++++++++++++ lib/shopify_api/admin_versions.rb | 1 + lib/shopify_api/context.rb | 6 +- .../errors/rest_resource_not_loaded_error.rb | 11 +++ test/admin_versions_test.rb | 15 +++ test/rest_resource_loading_test.rb | 96 +++++++++++++++++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 lib/shopify_api/errors/rest_resource_not_loaded_error.rb create mode 100644 test/rest_resource_loading_test.rb diff --git a/lib/shopify_api.rb b/lib/shopify_api.rb index 2414f6dab..9b36d0b90 100644 --- a/lib/shopify_api.rb +++ b/lib/shopify_api.rb @@ -22,3 +22,69 @@ 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) + raise Errors::RestResourceNotLoadedError, rest_resource_not_loaded_message(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..6851b5edf --- /dev/null +++ b/lib/shopify_api/errors/rest_resource_not_loaded_error.rb @@ -0,0 +1,11 @@ +# 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. + class RestResourceNotLoadedError < NameError; end + end +end 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..fa0194f85 --- /dev/null +++ b/test/rest_resource_loading_test.rb @@ -0,0 +1,96 @@ +# 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_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 From 561e5f3182bcced0fad0c7de8a2e8a97789b140b Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Mon, 27 Jul 2026 13:56:14 -0500 Subject: [PATCH 2/3] Add changelog entries for 2026-10 support and REST resource error --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From f97a8ac33ac80e2ed2d25661215823b545048617 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Mon, 27 Jul 2026 14:56:52 -0500 Subject: [PATCH 3/3] Require logger explicitly and preserve NameError#name Context builds a Logger when its class body loads, but the entrypoint never required Ruby's logger. Whether Logger is defined has been down to a transitive require from whichever dependency versions get resolved: outside this gem's own bundle, 'require "shopify_api"; ShopifyAPI::Product' raised 'uninitialized constant Logger'. The const_missing hook reads Context.api_version to build its message, so it turned a clean NameError into that confusing one. Require logger in the entrypoint and declare it in the gemspec, matching the existing openssl and securerandom entries. RestResourceNotLoadedError also dropped NameError#name, which Ruby populates on the error it would otherwise raise. Pass the constant name through so consumers inspecting error.name - not just rescuing NameError - keep working. --- Gemfile.lock | 1 + lib/shopify_api.rb | 5 ++- .../errors/rest_resource_not_loaded_error.rb | 7 ++-- shopify_api.gemspec | 1 + test/rest_resource_loading_test.rb | 35 +++++++++++++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) 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 9b36d0b90..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" @@ -37,7 +38,9 @@ class << self sig { params(name: Symbol).returns(T.untyped) } def const_missing(name) if bundled_rest_resource_names.include?(name.to_s) - raise Errors::RestResourceNotLoadedError, rest_resource_not_loaded_message(name) + # 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 diff --git a/lib/shopify_api/errors/rest_resource_not_loaded_error.rb b/lib/shopify_api/errors/rest_resource_not_loaded_error.rb index 6851b5edf..86c58e374 100644 --- a/lib/shopify_api/errors/rest_resource_not_loaded_error.rb +++ b/lib/shopify_api/errors/rest_resource_not_loaded_error.rb @@ -4,8 +4,11 @@ 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. + # 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/rest_resource_loading_test.rb b/test/rest_resource_loading_test.rb index fa0194f85..102bc6e55 100644 --- a/test/rest_resource_loading_test.rb +++ b/test/rest_resource_loading_test.rb @@ -34,6 +34,41 @@ def test_explains_that_resources_are_not_bundled_for_the_active_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)