Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased
- Scope REST resource response metadata to the active session lifecycle.

## 16.3.0 (2026-08-04)
- [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` with `sanitize_shop_domain` and `sanitize!`.
Expand Down
2 changes: 2 additions & 0 deletions docs/usage/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ _For more information on the `products` endpoint, [check out our API reference g

The REST resources have `api_call_limit` and `retry_after` can be found on the Resource class. These values correspond to the `X-Shopify-Shop-Api-Call-Limit` and `Retry-After` [headers](https://shopify.dev/docs/api/usage/rate-limits#rest-admin-api-rate-limits) respectively.

The REST resource response metadata is thread-local and scoped to the active session lifecycle. After activating or deactivating a session, these values return `nil` until another REST resource request is made on that thread.

```ruby
product = ShopifyAPI::Product.find(session: session, id: 12345)

Expand Down
10 changes: 10 additions & 0 deletions lib/shopify_api/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class Context
@log_level = T.let(:info, Symbol)
@notified_missing_resources_folder = T.let({}, T::Hash[String, T::Boolean])
@active_session = T.let(Concurrent::ThreadLocalVar.new { nil }, T.nilable(Concurrent::ThreadLocalVar))
# REST resource response metadata is stored per thread. This generation makes values from a previous
# session lifecycle invisible when a worker thread is reused.
@rest_response_state_generation = T.let(Concurrent::ThreadLocalVar.new { 0 }, Concurrent::ThreadLocalVar)
@user_agent_prefix = T.let(nil, T.nilable(String))
@old_api_secret_key = T.let(nil, T.nilable(String))
@response_as_struct = T.let(false, T.nilable(T::Boolean))
Expand Down Expand Up @@ -177,11 +180,18 @@ def active_session
sig { params(session: T.nilable(Auth::Session)).void }
def activate_session(session)
T.must(@active_session).value = session
@rest_response_state_generation.value += 1
end

sig { void }
def deactivate_session
T.must(@active_session).value = nil
@rest_response_state_generation.value += 1
end

sig { returns(Integer) }
def rest_response_state_generation
@rest_response_state_generation.value
end

sig { returns(String) }
Expand Down
33 changes: 23 additions & 10 deletions lib/shopify_api/rest/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,21 @@ def inherited(subclass)
end
def base_find(session: nil, ids: {}, params: {})
session ||= ShopifyAPI::Context.active_session
response_state_generation = ShopifyAPI::Context.rest_response_state_generation

client = ShopifyAPI::Clients::Rest::Admin.new(session: session)

path = T.must(get_path(http_method: :get, operation: :get, ids: ids))
response = client.get(path: path, query: params.compact, headers: headers)

instance_variable_get(:"@prev_page_info").value = response.prev_page_info
instance_variable_get(:"@next_page_info").value = response.next_page_info
instance_variable_get(:"@prev_page_info").value = [response_state_generation, response.prev_page_info]
instance_variable_get(:"@next_page_info").value = [response_state_generation, response.next_page_info]

instance_variable_get(:"@retry_request_after").value = response.retry_request_after
instance_variable_get(:"@api_call_limit").value = response.api_call_limit
instance_variable_get(:"@retry_request_after").value = [
response_state_generation,
response.retry_request_after,
]
instance_variable_get(:"@api_call_limit").value = [response_state_generation, response.api_call_limit]

create_instances_from_response(response: response, session: T.must(session))
end
Expand All @@ -124,39 +128,48 @@ def json_response_body_names

sig { returns(T.nilable(String)) }
def prev_page_info
instance_variable_get(:"@prev_page_info").value
response_metadata(:@prev_page_info)
end

sig { returns(T.nilable(String)) }
def next_page_info
instance_variable_get(:"@next_page_info").value
response_metadata(:@next_page_info)
end

sig { returns(T::Boolean) }
def prev_page?
!instance_variable_get(:"@prev_page_info").value.nil?
!prev_page_info.nil?
end

sig { returns(T::Boolean) }
def next_page?
!instance_variable_get(:"@next_page_info").value.nil?
!next_page_info.nil?
end

sig { returns T.nilable(Float) }
def retry_request_after
instance_variable_get(:"@retry_request_after").value
response_metadata(:@retry_request_after)
end

sig { returns T.nilable(T::Hash[String, Integer]) }
def api_call_limit
instance_variable_get(:"@api_call_limit").value
response_metadata(:@api_call_limit)
end

sig { params(attribute: Symbol).returns(T::Boolean) }
def has_many?(attribute)
@has_many.include?(attribute)
end

sig { params(attribute: Symbol).returns(T.untyped) }
def response_metadata(attribute)
value = instance_variable_get(attribute).value
return nil unless value.is_a?(Array) && value[0] == ShopifyAPI::Context.rest_response_state_generation

value[1]
end
private :response_metadata

sig { params(attribute: Symbol).returns(T::Boolean) }
def has_one?(attribute)
@has_one.include?(attribute)
Expand Down
38 changes: 38 additions & 0 deletions test/clients/base_rest_resource_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,44 @@ def test_api_limit_headers
assert(TestHelpers::FakeResource.api_call_limit[:bucket_size], 40)
end

def test_response_metadata_is_scoped_to_active_session
body = { fake_resources: [] }.to_json

stub_request(:get, "#{@prefix}/fake_resources.json")
.to_return(body: body, headers: {
"link" => "<#{@prefix}/fake_resources.json?page_info=next-page>; rel=\"next\", " \
"<#{@prefix}/fake_resources.json?page_info=previous-page>; rel=\"previous\"",
"X-Shopify-Shop-Api-Call-Limit" => "40/40",
"Retry-After" => "2.0",
})

other_session = ShopifyAPI::Auth::Session.new(shop: "another-shop.myshopify.com",
access_token: SecureRandom.alphanumeric(10))

ShopifyAPI::Context.deactivate_session
ShopifyAPI::Context.activate_session(@session)
TestHelpers::FakeResource.base_find

assert(TestHelpers::FakeResource.next_page?)
assert(TestHelpers::FakeResource.prev_page?)
assert_equal("next-page", TestHelpers::FakeResource.next_page_info)
assert_equal("previous-page", TestHelpers::FakeResource.prev_page_info)
assert_equal(2.0, TestHelpers::FakeResource.retry_request_after)
assert_equal({ request_count: 40, bucket_size: 40 }, TestHelpers::FakeResource.api_call_limit)

ShopifyAPI::Context.deactivate_session
ShopifyAPI::Context.activate_session(other_session)

refute(TestHelpers::FakeResource.next_page?)
refute(TestHelpers::FakeResource.prev_page?)
assert_nil(TestHelpers::FakeResource.next_page_info)
assert_nil(TestHelpers::FakeResource.prev_page_info)
assert_nil(TestHelpers::FakeResource.retry_request_after)
assert_nil(TestHelpers::FakeResource.api_call_limit)
ensure
ShopifyAPI::Context.deactivate_session
end

def test_pagination_is_thread_safe
response_body = { fake_resources: [] }.to_json
request_made = false
Expand Down
Loading