Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ PATH
hash_diff
httparty
jwt
logger
oj
openssl
securerandom
Expand Down
69 changes: 69 additions & 0 deletions lib/shopify_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require "securerandom"
require "cgi"
require "uri"
require "logger"
require "openssl"
require "httparty"
require "zeitwerk"
Expand All @@ -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
1 change: 1 addition & 0 deletions lib/shopify_api/admin_versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module ShopifyAPI
module AdminVersions
SUPPORTED_ADMIN_VERSIONS = T.let([
"unstable",
"2026-10",
"2026-07",
"2026-04",
"2026-01",
Expand Down
6 changes: 5 additions & 1 deletion lib/shopify_api/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions lib/shopify_api/errors/rest_resource_not_loaded_error.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions shopify_api.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions test/admin_versions_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
131 changes: 131 additions & 0 deletions test/rest_resource_loading_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading