diff --git a/.gitignore b/.gitignore index b6336c5..c800d72 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ measurement/* pkg/* .DS_Store .env -demo.rb \ No newline at end of file +demo.rb +.ruby-lsp \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index e6eddc7..4abb387 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,6 +20,10 @@ Layout/LineLength: Exclude: - ./*.gemspec +Metrics/ClassLength: + CountComments: false + Max: 120 + Metrics/MethodLength: CountComments: false Max: 10 diff --git a/docs/Gemfile b/docs/Gemfile index 0d40cea..aa18ad5 100644 --- a/docs/Gemfile +++ b/docs/Gemfile @@ -27,7 +27,7 @@ end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem # and associated library. install_if -> { RUBY_PLATFORM =~ /mingw|mswin|java/ } do - gem 'tzinfo', '~> 1.2' + gem 'tzinfo', '~> 2.0' gem 'tzinfo-data' end diff --git a/docs/additional_info/changelog.md b/docs/additional_info/changelog.md index 8e7670a..35a1fa8 100644 --- a/docs/additional_info/changelog.md +++ b/docs/additional_info/changelog.md @@ -1,5 +1,25 @@ # Changelog +## 9.1.0 (15-May-2024) + +* Add support for [cursor pagination](https://lokalise.github.io/ruby-lokalise-api/api/getting-started#cursor-pagination) for List keys and List translation endpoints: + +```ruby +cursor_pagination_params = { + pagination: 'cursor', + cursor: 'eyIxIjozMTk3ODIzNzJ9', # The starting cursor. Optional, string + limit: 2 # The number of items to fetch. Optional, default is 100 +} + +keys = @client.keys '123abcdef.01', cursor_pagination_params + +keys.next_cursor? # => true +keys.next_cursor # => 'eyIxIjozMTk3ODIzNzV9' + +# Request keys from the next cursor (returns `nil` if the next cursor is not available): +keys_next_cursor = keys.load_next_cursor +``` + ## 9.0.1 (13-Mar-2024) * Handle cases when server responds with non-JSON diff --git a/docs/api/getting-started.md b/docs/api/getting-started.md index b9d67a9..4429856 100644 --- a/docs/api/getting-started.md +++ b/docs/api/getting-started.md @@ -138,6 +138,26 @@ first_translation = prev_page_translations.first # get first translation from th first_translation.translation # => 'Translation text' ``` +### Cursor pagination + +The [List Keys](https://developers.lokalise.com/reference/list-all-keys) and [List Translations](https://developers.lokalise.com/reference/list-all-translations) endpoints support cursor pagination, which is recommended for its faster performance compared to traditional "offset" pagination. By default, "offset" pagination is used, so you must explicitly set `pagination` to `"cursor"` to use cursor pagination. + +```ruby +cursor_pagination_params = { + pagination: 'cursor', + cursor: 'eyIxIjozMTk3ODIzNzJ9', # The starting cursor. Optional, string + limit: 2 # The number of items to fetch. Optional, default is 100 +} + +keys = @client.keys '123abcdef.01', cursor_pagination_params + +keys.next_cursor? # => true +keys.next_cursor # => 'eyIxIjozMTk3ODIzNzV9' + +# Request keys from the next cursor (returns `nil` if the next cursor is not available): +keys_next_cursor = keys.load_next_cursor +``` + ## Branching If you are using [project branching feature](https://docs.lokalise.com/en/articles/3391861-project-branching), simply add branch name separated by semicolon to your project ID in any endpoint to access the branch. For example, in order to access `new-feature` branch for the project with an id `123abcdef.01`: diff --git a/docs/api/keys.md b/docs/api/keys.md index 83e427d..99e6cb2 100644 --- a/docs/api/keys.md +++ b/docs/api/keys.md @@ -8,11 +8,13 @@ @client.keys(project_id, params = {}) # Input: ## project_id (string, required) ## params (hash) - ### :page and :limit + ### pagination and cursor-related params # Output: ## Collection of keys available in the given project ``` +**This endpoint also supports cursor pagination which is now a recommended approach, especially for fetching large amounts of data. Please [learn more in the Pagination docs](https://lokalise.github.io/ruby-lokalise-api/api/getting-started#cursor-pagination).** + For example: ```ruby diff --git a/docs/api/translations.md b/docs/api/translations.md index b1db014..d0e8f14 100644 --- a/docs/api/translations.md +++ b/docs/api/translations.md @@ -9,11 +9,12 @@ ## project_id (string, required) ## params (hash) ### Find full list in the docs - ### :page and :limit # Output: ## Collection of translations for the project ``` +**This endpoint also supports cursor pagination which is now a recommended approach, especially for fetching large amounts of data. Please [learn more in the Pagination docs](https://lokalise.github.io/ruby-lokalise-api/api/getting-started#cursor-pagination).** + For example: ```ruby diff --git a/lib/ruby_lokalise_api/collections/base.rb b/lib/ruby_lokalise_api/collections/base.rb index 7fbcbea..15a9f1a 100644 --- a/lib/ruby_lokalise_api/collections/base.rb +++ b/lib/ruby_lokalise_api/collections/base.rb @@ -18,7 +18,7 @@ class Base def_delegators :collection, :[], :last, :each attr_reader :total_pages, :total_results, :results_per_page, :current_page, - :collection + :collection, :next_cursor def initialize(response) @self_endpoint = response.endpoint @@ -32,9 +32,25 @@ def initialize(response) def next_page return nil if last_page? + override_params = { page: current_page + 1 } + self.class.new( reinit_endpoint( - override_req_params: { page: current_page + 1 } + override_req_params: override_params + ).do_get + ) + end + + # Tries to fetch the next cursor for paginated collection + # Returns a new collection or nil if the next cursor is not available + def load_next_cursor + return nil unless next_cursor? + + override_params = { cursor: next_cursor } + + self.class.new( + reinit_endpoint( + override_req_params: override_params ).do_get ) end @@ -75,6 +91,12 @@ def first_page? !prev_page? end + # Checks whether the next cursor is available + # @return [Boolean] + def next_cursor? + !next_cursor.nil? && next_cursor != '' + end + private # This method is utilized to recreate an endpoint for the current collection @@ -89,14 +111,17 @@ def populate_common_attrs_from(response) instance_variable_set :"@#{attrib}", response.content[attrib] end - headers = response.headers + extract_common_from_headers(response.headers) + end + def extract_common_from_headers(headers) return unless headers.any? - @total_results = headers[:'x-pagination-total-count'] - @total_pages = headers[:'x-pagination-page-count'] - @results_per_page = headers[:'x-pagination-limit'] - @current_page = headers[:'x-pagination-page'] + @total_results = headers[:'x-pagination-total-count'].to_i + @total_pages = headers[:'x-pagination-page-count'].to_i + @results_per_page = headers[:'x-pagination-limit'].to_i + @current_page = headers[:'x-pagination-page'].to_i + @next_cursor = headers[:'x-pagination-next-cursor'] end def produce_collection_from(response) diff --git a/lib/ruby_lokalise_api/response.rb b/lib/ruby_lokalise_api/response.rb index dc43cc3..41a8ae5 100644 --- a/lib/ruby_lokalise_api/response.rb +++ b/lib/ruby_lokalise_api/response.rb @@ -5,7 +5,7 @@ module RubyLokaliseApi class Response # Lokalise returns pagination info in special headers PAGINATION_HEADERS = %w[x-pagination-total-count x-pagination-page-count x-pagination-limit - x-pagination-page].freeze + x-pagination-page x-pagination-next-cursor].freeze attr_reader :content, :endpoint, :headers @@ -43,8 +43,7 @@ def extract_headers_from(raw_headers) raw_headers. to_h. keep_if { |header, _value| pagination_headers.include?(header) }. - transform_keys(&:to_sym). - transform_values(&:to_i) + transform_keys(&:to_sym) end end end diff --git a/lib/ruby_lokalise_api/version.rb b/lib/ruby_lokalise_api/version.rb index 12b3987..eb0edf7 100644 --- a/lib/ruby_lokalise_api/version.rb +++ b/lib/ruby_lokalise_api/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RubyLokaliseApi - VERSION = '9.0.1' + VERSION = '9.1.0' end diff --git a/spec/fixtures/keys/keys_cursor_1.json b/spec/fixtures/keys/keys_cursor_1.json new file mode 100644 index 0000000..77f1c32 --- /dev/null +++ b/spec/fixtures/keys/keys_cursor_1.json @@ -0,0 +1,75 @@ +{ + "project_id": "88628569645b945648b474.25982965", + "branch": "master", + "keys": [ + { + "key_id": 319782369, + "created_at": "2023-04-06 17:49:36 (Etc/UTC)", + "created_at_timestamp": 1680803376, + "key_name": { + "ios": "hi", + "android": "hi", + "web": "hi", + "other": "hi" + }, + "filenames": { + "ios": "", + "android": "", + "web": "", + "other": "" + }, + "description": "Welcoming message", + "platforms": [ + "ios", + "web" + ], + "tags": [], + "is_plural": false, + "plural_name": "", + "is_hidden": false, + "is_archived": false, + "context": "", + "base_words": 3, + "char_limit": 0, + "custom_attributes": "", + "modified_at": "2023-05-09 11:59:42 (Etc/UTC)", + "modified_at_timestamp": 1683633582, + "translations_modified_at": "2023-05-18 12:55:25 (Etc/UTC)", + "translations_modified_at_timestamp": 1684414525 + }, + { + "key_id": 319782372, + "created_at": "2023-04-18 16:40:16 (Etc/UTC)", + "created_at_timestamp": 1681836016, + "key_name": { + "ios": "editor", + "android": "editor", + "web": "editor", + "other": "editor" + }, + "filenames": { + "ios": "", + "android": "", + "web": "", + "other": "" + }, + "description": "", + "platforms": [ + "android" + ], + "tags": [], + "is_plural": false, + "plural_name": "", + "is_hidden": false, + "is_archived": false, + "context": "", + "base_words": 1, + "char_limit": 0, + "custom_attributes": "", + "modified_at": "2023-04-24 11:28:17 (Etc/UTC)", + "modified_at_timestamp": 1682335697, + "translations_modified_at": "2023-05-18 12:55:19 (Etc/UTC)", + "translations_modified_at_timestamp": 1684414519 + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/keys/keys_cursor_2.json b/spec/fixtures/keys/keys_cursor_2.json new file mode 100644 index 0000000..734ff40 --- /dev/null +++ b/spec/fixtures/keys/keys_cursor_2.json @@ -0,0 +1,75 @@ +{ + "project_id": "88628569645b945648b474.25982965", + "branch": "master", + "keys": [ + { + "key_id": 319782375, + "created_at": "2023-04-24 11:24:38 (Etc/UTC)", + "created_at_timestamp": 1682335478, + "key_name": { + "ios": "how_are_you", + "android": "how_are_you", + "web": "how_are_you", + "other": "how_are_you" + }, + "filenames": { + "ios": "", + "android": "", + "web": "main-%LANG_ISO%.json", + "other": "" + }, + "description": "", + "platforms": [ + "web" + ], + "tags": [], + "is_plural": false, + "plural_name": "", + "is_hidden": false, + "is_archived": false, + "context": "", + "base_words": 4, + "char_limit": 0, + "custom_attributes": "", + "modified_at": "2023-04-24 11:24:38 (Etc/UTC)", + "modified_at_timestamp": 1682335478, + "translations_modified_at": "2023-05-18 12:55:17 (Etc/UTC)", + "translations_modified_at_timestamp": 1684414517 + }, + { + "key_id": 319782376, + "created_at": "2023-04-24 11:24:38 (Etc/UTC)", + "created_at_timestamp": 1682335478, + "key_name": { + "ios": "login", + "android": "login", + "web": "login", + "other": "login" + }, + "filenames": { + "ios": "", + "android": "", + "web": "main-%LANG_ISO%.json", + "other": "" + }, + "description": "", + "platforms": [ + "ios", + "web" + ], + "tags": [], + "is_plural": false, + "plural_name": "", + "is_hidden": false, + "is_archived": false, + "context": "", + "base_words": 2, + "char_limit": 0, + "custom_attributes": "", + "modified_at": "2023-05-10 13:10:57 (Etc/UTC)", + "modified_at_timestamp": 1683724257, + "translations_modified_at": "2023-05-18 12:55:16 (Etc/UTC)", + "translations_modified_at_timestamp": 1684414516 + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/translations/translations_cursor_1.json b/spec/fixtures/translations/translations_cursor_1.json new file mode 100644 index 0000000..1d7b8f5 --- /dev/null +++ b/spec/fixtures/translations/translations_cursor_1.json @@ -0,0 +1,48 @@ +{ + "project_id": "88628569645b945648b474.25982965", + "branch": "master", + "translations": [ + { + "translation_id": 2574122386, + "segment_number": 1, + "key_id": 319782369, + "language_iso": "lv", + "translation": "Laipni lūgti Lokalise!", + "modified_by": 49436, + "modified_by_email": "ilya@lokalise.com", + "modified_at": "2023-05-19 15:51:23 (Etc/UTC)", + "modified_at_timestamp": 1684511483, + "is_reviewed": false, + "reviewed_by": 0, + "is_unverified": true, + "is_fuzzy": true, + "words": 3, + "custom_translation_statuses": [ + { + "status_id": 14461, + "title": "wip", + "color": "#f2d600" + } + ], + "task_id": null + }, + { + "translation_id": 2574122400, + "segment_number": 1, + "key_id": 319782372, + "language_iso": "en", + "translation": "Editor", + "modified_by": 20181, + "modified_by_email": "bodrovis@protonmail.com", + "modified_at": "2023-04-18 16:40:16 (Etc/UTC)", + "modified_at_timestamp": 1681836016, + "is_reviewed": false, + "reviewed_by": 0, + "is_unverified": false, + "is_fuzzy": false, + "words": 1, + "custom_translation_statuses": [], + "task_id": null + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/translations/translations_cursor_2.json b/spec/fixtures/translations/translations_cursor_2.json new file mode 100644 index 0000000..7112563 --- /dev/null +++ b/spec/fixtures/translations/translations_cursor_2.json @@ -0,0 +1,42 @@ +{ + "project_id": "88628569645b945648b474.25982965", + "branch": "master", + "translations": [ + { + "translation_id": 2658834453, + "segment_number": 1, + "key_id": 319782372, + "language_iso": "de", + "translation": "", + "modified_by": 20181, + "modified_by_email": "bodrovis@protonmail.com", + "modified_at": "2023-05-31 13:50:28 (Etc/UTC)", + "modified_at_timestamp": 1685541028, + "is_reviewed": false, + "reviewed_by": 0, + "is_unverified": true, + "is_fuzzy": true, + "words": 0, + "custom_translation_statuses": [], + "task_id": null + }, + { + "translation_id": 2574122399, + "segment_number": 1, + "key_id": 319782372, + "language_iso": "fr", + "translation": "Éditeur", + "modified_by": 49436, + "modified_by_email": "ilya@lokalise.com", + "modified_at": "2023-05-18 12:42:23 (Etc/UTC)", + "modified_at_timestamp": 1684413743, + "is_reviewed": false, + "reviewed_by": 0, + "is_unverified": false, + "is_fuzzy": false, + "words": 1, + "custom_translation_statuses": [], + "task_id": null + } + ] +} \ No newline at end of file diff --git a/spec/lib/ruby_lokalise_api/collections/keys_spec.rb b/spec/lib/ruby_lokalise_api/collections/keys_spec.rb index c6ec8a9..6e0e587 100644 --- a/spec/lib/ruby_lokalise_api/collections/keys_spec.rb +++ b/spec/lib/ruby_lokalise_api/collections/keys_spec.rb @@ -11,6 +11,60 @@ 'x-pagination-page': '2' } end + let(:cursor_params) do + { + cursor: 'eyIxIjozMTk3ODIzNzJ9', + pagination: 'cursor', + limit: 2 + } + end + let(:cursor_headers) do + { + 'x-pagination-limit': cursor_params[:limit], + 'x-pagination-next-cursor': 'eyIxIjozMTk3ODIzNzV9' + } + end + + it 'supports cursor pagination' do + stub( + uri: "projects/#{project_id}/keys", + req: { query: cursor_params }, + resp: { + body: fixture('keys/keys_cursor_1'), + headers: cursor_headers + } + ) + + stub( + uri: "projects/#{project_id}/keys", + req: { query: cursor_params.merge(cursor: 'eyIxIjozMTk3ODIzNzV9') }, + resp: { + body: fixture('keys/keys_cursor_2'), + headers: cursor_headers.merge('x-pagination-next-cursor': 'eyIxIjozMjQ3MjkyMTd9') + } + ) + + keys = test_client.keys project_id, cursor_params + + expect(keys.collection.length).to eq(2) + expect(keys[0].key_id).to eq(319_782_369) + expect_to_have_valid_resources(keys) + expect(keys.next_page?).to be false + expect(keys.prev_page?).to be false + expect(keys.next_cursor?).to be true + expect(keys.next_cursor).to eq('eyIxIjozMTk3ODIzNzV9') + + expect(keys.prev_page).to be_nil + expect(keys.next_page).to be_nil + + next_cursor_keys = keys.load_next_cursor + expect(next_cursor_keys.collection.length).to eq(2) + expect(next_cursor_keys[0].key_id).to eq(319_782_375) + expect(next_cursor_keys.next_page?).to be false + expect(next_cursor_keys.prev_page?).to be false + expect(next_cursor_keys.next_cursor?).to be true + expect(next_cursor_keys.next_cursor).to eq('eyIxIjozMjQ3MjkyMTd9') + end it 'supports pagination' do stub( @@ -38,6 +92,7 @@ expect_to_have_valid_resources(keys) expect(keys.next_page?).to be false expect(keys.prev_page?).to be true + expect(keys.next_cursor?).to be false prev_page_keys = keys.prev_page diff --git a/spec/lib/ruby_lokalise_api/collections/translations_spec.rb b/spec/lib/ruby_lokalise_api/collections/translations_spec.rb index 1745a7a..86f4f7d 100644 --- a/spec/lib/ruby_lokalise_api/collections/translations_spec.rb +++ b/spec/lib/ruby_lokalise_api/collections/translations_spec.rb @@ -12,6 +12,63 @@ 'x-pagination-page': '4' } end + let(:cursor_params) do + { + cursor: 'eyIxIjoyNjU4ODM0NDUyfQ==', + pagination: 'cursor', + limit: 2 + } + end + let(:cursor_headers) do + { + 'x-pagination-limit': cursor_params[:limit], + 'x-pagination-next-cursor': 'eyIxIjoyNjU4ODM0NDUzfQ==' + } + end + + it 'supports cursor pagination' do + stub( + uri: "projects/#{project_id}/translations", + req: { query: cursor_params }, + resp: { + body: fixture('translations/translations_cursor_1'), + headers: cursor_headers + } + ) + + stub( + uri: "projects/#{project_id}/translations", + req: { query: cursor_params.merge(cursor: 'eyIxIjoyNjU4ODM0NDUzfQ==') }, + resp: { + body: fixture('translations/translations_cursor_2'), + headers: { 'x-pagination-limit': cursor_params[:limit] } + } + ) + + translations = test_client.translations project_id, cursor_params + + expect(translations.collection.length).to eq(2) + expect(translations[0].translation_id).to eq(2_574_122_386) + expect_to_have_valid_resources(translations) + expect(translations.next_page?).to be false + expect(translations.prev_page?).to be false + expect(translations.next_cursor?).to be true + expect(translations.next_cursor).to eq('eyIxIjoyNjU4ODM0NDUzfQ==') + + expect(translations.prev_page).to be_nil + expect(translations.next_page).to be_nil + + next_cursor_translations = translations.load_next_cursor + + expect(next_cursor_translations.collection.length).to eq(2) + expect(next_cursor_translations[0].translation_id).to eq(2_658_834_453) + expect(next_cursor_translations.next_page?).to be false + expect(next_cursor_translations.prev_page?).to be false + expect(next_cursor_translations.next_cursor?).to be false + expect(next_cursor_translations.next_cursor).to be_nil + + expect(next_cursor_translations.load_next_cursor).to be_nil + end it 'supports pagination' do stub( diff --git a/spec/lib/ruby_lokalise_api/rest/keys_spec.rb b/spec/lib/ruby_lokalise_api/rest/keys_spec.rb index c07c67a..518b866 100644 --- a/spec/lib/ruby_lokalise_api/rest/keys_spec.rb +++ b/spec/lib/ruby_lokalise_api/rest/keys_spec.rb @@ -57,23 +57,60 @@ end end - specify '#keys' do - stub( - uri: "projects/#{project_id}/keys", - resp: { body: fixture('keys/keys') } - ) + describe '#keys' do + it 'fetches all keys' do + stub( + uri: "projects/#{project_id}/keys", + resp: { body: fixture('keys/keys') } + ) - keys = test_client.keys project_id - expect(keys.collection.length).to eq(5) - expect(keys).to be_an_instance_of(RubyLokaliseApi::Collections::Keys) - expect_to_have_valid_resources(keys) - expect(keys.project_id).to eq(project_id) - expect(keys.branch).to eq('master') + keys = test_client.keys project_id + expect(keys.collection.length).to eq(5) + expect(keys).to be_an_instance_of(RubyLokaliseApi::Collections::Keys) + expect_to_have_valid_resources(keys) + expect(keys.project_id).to eq(project_id) + expect(keys.branch).to eq('master') - key = keys[0] + key = keys[0] - expect(key.key_id).to eq(319_782_369) - expect(key.project_id).to eq(project_id) + expect(key.key_id).to eq(319_782_369) + expect(key.project_id).to eq(project_id) + end + + it 'fetches keys with cursor' do + cursor_params = { + pagination: 'cursor', + limit: 2 + } + + stub( + uri: "projects/#{project_id}/keys", + req: { query: cursor_params }, + resp: { + body: fixture('keys/keys'), + headers: { + 'x-pagination-limit': '2', + 'x-pagination-next-cursor': 'eyIxIjozMTk3ODIzNzJ9' + } + } + ) + + keys = test_client.keys project_id, cursor_params + + expect(keys.collection.length).to eq(5) + expect(keys).to be_an_instance_of(RubyLokaliseApi::Collections::Keys) + expect_to_have_valid_resources(keys) + expect(keys.project_id).to eq(project_id) + expect(keys.branch).to eq('master') + + expect(keys.next_cursor?).to be true + expect(keys.next_cursor).to eq('eyIxIjozMTk3ODIzNzJ9') + + key = keys[0] + + expect(key.key_id).to eq(319_782_369) + expect(key.project_id).to eq(project_id) + end end specify '#create_keys' do diff --git a/spec/lib/ruby_lokalise_api/rest/translations_spec.rb b/spec/lib/ruby_lokalise_api/rest/translations_spec.rb index 8d1e7b7..dc3b818 100644 --- a/spec/lib/ruby_lokalise_api/rest/translations_spec.rb +++ b/spec/lib/ruby_lokalise_api/rest/translations_spec.rb @@ -4,21 +4,56 @@ let(:project_id) { '88628569645b945648b474.25982965' } let(:translation_id) { 2_574_122_388 } - specify '#translations' do - stub( - uri: "projects/#{project_id}/translations", - resp: { body: fixture('translations/translations') } - ) + describe '#translations' do + it 'fetches all translations' do + stub( + uri: "projects/#{project_id}/translations", + resp: { body: fixture('translations/translations') } + ) - translations = test_client.translations project_id + translations = test_client.translations project_id - expect(translations.collection.length).to eq(3) - expect_to_have_valid_resources(translations) - expect(translations.project_id).to eq(project_id) + expect(translations.collection.length).to eq(3) + expect_to_have_valid_resources(translations) + expect(translations.project_id).to eq(project_id) - translation = translations[0] + translation = translations[0] - expect(translation.translation_id).to eq(translation_id) + expect(translation.translation_id).to eq(translation_id) + end + + it 'fetches translation with cursor' do + cursor_params = { + pagination: 'cursor', + limit: 2, + cursor: 'eyIxIjoyNjU4ODM0NDUyfQ==' + } + + stub( + uri: "projects/#{project_id}/translations", + req: { query: cursor_params }, + resp: { + body: fixture('translations/translations'), + headers: { + 'x-pagination-limit': '2', + 'x-pagination-next-cursor': 'eyIxIjoyNjU4ODM0NDUzfQ==' + } + } + ) + + translations = test_client.translations project_id, cursor_params + + expect(translations.collection.length).to eq(3) + expect_to_have_valid_resources(translations) + expect(translations.project_id).to eq(project_id) + + expect(translations.next_cursor?).to be true + expect(translations.next_cursor).to eq('eyIxIjoyNjU4ODM0NDUzfQ==') + + translation = translations[0] + + expect(translation.translation_id).to eq(translation_id) + end end specify '#translation' do