diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f20cf2f..bd0bee867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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!`. diff --git a/docs/usage/rest.md b/docs/usage/rest.md index 4d29010be..0310982f3 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -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) diff --git a/lib/shopify_api/context.rb b/lib/shopify_api/context.rb index 19a18bfa9..a2381d6fe 100644 --- a/lib/shopify_api/context.rb +++ b/lib/shopify_api/context.rb @@ -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)) @@ -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) } diff --git a/lib/shopify_api/rest/base.rb b/lib/shopify_api/rest/base.rb index 3cc4ebd18..108156deb 100644 --- a/lib/shopify_api/rest/base.rb +++ b/lib/shopify_api/rest/base.rb @@ -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 @@ -124,32 +128,32 @@ 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) } @@ -157,6 +161,15 @@ 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) diff --git a/test/clients/base_rest_resource_test.rb b/test/clients/base_rest_resource_test.rb index 78a2e19c8..820c7e328 100644 --- a/test/clients/base_rest_resource_test.rb +++ b/test/clients/base_rest_resource_test.rb @@ -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