diff --git a/lib/gitlab/graphql/connection_collection_methods.rb b/lib/gitlab/graphql/connection_collection_methods.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e2c4a98bb62eed18848d09b3b10ff7ca2f36336 --- /dev/null +++ b/lib/gitlab/graphql/connection_collection_methods.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module ConnectionCollectionMethods + extend ActiveSupport::Concern + + included do + delegate :to_a, :size, :include?, :empty?, to: :nodes + end + end + end +end diff --git a/lib/gitlab/graphql/connection_redaction.rb b/lib/gitlab/graphql/connection_redaction.rb new file mode 100644 index 0000000000000000000000000000000000000000..5e037bb9f6394fdba4aadc044015437eae01ab3e --- /dev/null +++ b/lib/gitlab/graphql/connection_redaction.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + module ConnectionRedaction + class RedactionState + attr_reader :redactor + attr_reader :redacted_nodes + + def redactor=(redactor) + @redactor = redactor + @redacted_nodes = nil + end + + def redacted(&block) + @redacted_nodes ||= redactor.present? ? redactor.redact(yield) : yield + end + end + + delegate :redactor=, to: :redaction_state + + def nodes + redaction_state.redacted { super.to_a } + end + + private + + def redaction_state + @redaction_state ||= RedactionState.new + end + end + end +end diff --git a/lib/gitlab/graphql/pagination/array_connection.rb b/lib/gitlab/graphql/pagination/array_connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..efc912eaeca7e60527572bd42e5746b02c37f49b --- /dev/null +++ b/lib/gitlab/graphql/pagination/array_connection.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# We use the Keyset / Stable cursor connection by default for ActiveRecord::Relation. +# However, there are times when that may not be powerful enough (yet), and we +# want to use standard offset pagination. +module Gitlab + module Graphql + module Pagination + class ArrayConnection < ::GraphQL::Pagination::ArrayConnection + prepend ::Gitlab::Graphql::ConnectionRedaction + include ::Gitlab::Graphql::ConnectionCollectionMethods + end + end + end +end diff --git a/lib/gitlab/graphql/pagination/connections.rb b/lib/gitlab/graphql/pagination/connections.rb index 8f37fa3f474100970e40b6547a4a6388816904e7..54a84be4274b30ae4842a75e2e830511974bcc60 100644 --- a/lib/gitlab/graphql/pagination/connections.rb +++ b/lib/gitlab/graphql/pagination/connections.rb @@ -12,6 +12,10 @@ def self.use(schema) schema.connections.add( Gitlab::Graphql::ExternallyPaginatedArray, Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection) + + schema.connections.add( + Array, + Gitlab::Graphql::Pagination::ArrayConnection) end end end diff --git a/lib/gitlab/graphql/pagination/externally_paginated_array_connection.rb b/lib/gitlab/graphql/pagination/externally_paginated_array_connection.rb index 12e047420bf54dafc0288cb053817affb2e3a5f2..90a20861b0d2907e18f96dc07f8bf568e598e9fc 100644 --- a/lib/gitlab/graphql/pagination/externally_paginated_array_connection.rb +++ b/lib/gitlab/graphql/pagination/externally_paginated_array_connection.rb @@ -5,6 +5,9 @@ module Gitlab module Graphql module Pagination class ExternallyPaginatedArrayConnection < GraphQL::Pagination::ArrayConnection + include ::Gitlab::Graphql::ConnectionCollectionMethods + prepend ::Gitlab::Graphql::ConnectionRedaction + def start_cursor items.previous_cursor end diff --git a/lib/gitlab/graphql/pagination/keyset/connection.rb b/lib/gitlab/graphql/pagination/keyset/connection.rb index 252f63717657edd4be8251c8806b877d0d724470..2ad8d2f7ab7ca83c72c2e06e668d41dcde5c5330 100644 --- a/lib/gitlab/graphql/pagination/keyset/connection.rb +++ b/lib/gitlab/graphql/pagination/keyset/connection.rb @@ -31,6 +31,8 @@ module Pagination module Keyset class Connection < GraphQL::Pagination::ActiveRecordRelationConnection include Gitlab::Utils::StrongMemoize + include ::Gitlab::Graphql::ConnectionCollectionMethods + prepend ::Gitlab::Graphql::ConnectionRedaction # rubocop: disable Naming/PredicateName # https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo.Fields diff --git a/lib/gitlab/graphql/pagination/offset_active_record_relation_connection.rb b/lib/gitlab/graphql/pagination/offset_active_record_relation_connection.rb index 33f847015620f10d7055e26d414ad9ba8c11b792..4a57b7aceca0de2529d18281cce57331984c4917 100644 --- a/lib/gitlab/graphql/pagination/offset_active_record_relation_connection.rb +++ b/lib/gitlab/graphql/pagination/offset_active_record_relation_connection.rb @@ -7,6 +7,8 @@ module Gitlab module Graphql module Pagination class OffsetActiveRecordRelationConnection < GraphQL::Pagination::ActiveRecordRelationConnection + prepend ::Gitlab::Graphql::ConnectionRedaction + include ::Gitlab::Graphql::ConnectionCollectionMethods end end end diff --git a/spec/lib/gitlab/graphql/pagination/array_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/array_connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..03cf53bb9903b0b15cb3b1ac02f3876192ba2b72 --- /dev/null +++ b/spec/lib/gitlab/graphql/pagination/array_connection_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Graphql::Pagination::ArrayConnection do + let(:nodes) { (1..10) } + + subject(:connection) { described_class.new(nodes, max_page_size: 100) } + + it_behaves_like 'a connection with collection methods' + + it_behaves_like 'a redactable connection' do + let(:unwanted) { 5 } + end +end diff --git a/spec/lib/gitlab/graphql/pagination/externally_paginated_array_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/externally_paginated_array_connection_spec.rb index 932bcd8cd92a6b9eaf1d143588077f1bdae2afa2..84e8f8b95e86a294059a9bf9b25327989044e1d6 100644 --- a/spec/lib/gitlab/graphql/pagination/externally_paginated_array_connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/externally_paginated_array_connection_spec.rb @@ -13,6 +13,12 @@ described_class.new(all_nodes, { max_page_size: values.size }.merge(arguments)) end + it_behaves_like 'a connection with collection methods' + + it_behaves_like 'a redactable connection' do + let(:unwanted) { 3 } + end + describe '#nodes' do let(:paged_nodes) { connection.nodes } diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb index c8f368b15fc78a10982111631d238dc260c93ec2..1fee24bdc1ff3be05a503bd7be0bdeb628a02ee0 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb @@ -21,6 +21,13 @@ def decoded_cursor(cursor) Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor)) end + it_behaves_like 'a connection with collection methods' + + it_behaves_like 'a redactable connection' do + let_it_be(:projects) { create_list(:project, 2) } + let(:unwanted) { projects.second } + end + describe '#cursor_for' do let(:project) { create(:project) } let(:cursor) { connection.cursor_for(project) } diff --git a/spec/lib/gitlab/graphql/pagination/offset_active_record_relation_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/offset_active_record_relation_connection_spec.rb index 86f35de94ed27632f34e40e69cec24f165078d25..1ca7c1c3c6951843c4a36d6a8c1f53e712b9d3e8 100644 --- a/spec/lib/gitlab/graphql/pagination/offset_active_record_relation_connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/offset_active_record_relation_connection_spec.rb @@ -6,4 +6,15 @@ it 'subclasses from GraphQL::Relay::RelationConnection' do expect(described_class.superclass).to eq GraphQL::Pagination::ActiveRecordRelationConnection end + + it_behaves_like 'a connection with collection methods' do + let(:connection) { described_class.new(Project.all) } + end + + it_behaves_like 'a redactable connection' do + let_it_be(:users) { create_list(:user, 2) } + + let(:connection) { described_class.new(User.all, max_page_size: 10) } + let(:unwanted) { users.second } + end end diff --git a/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb b/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..12a7b3fe414e2c02c01fac1041f50cdccfb9d9ee --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# requires: +# - `connection` (no-empty, containing `unwanted` and at least one more item) +# - `unwanted` (single item in collection) +RSpec.shared_examples 'a redactable connection' do + context 'no redactor set' do + it 'contains the unwanted item' do + expect(connection.nodes).to include(unwanted) + end + + it 'does not redact more than once' do + connection.nodes + r_state = connection.send(:redaction_state) + + expect(r_state.redacted { raise 'Should not be called!' }).to be_present + end + end + + let_it_be(:constant_redactor) do + Class.new do + def initialize(remove) + @remove = remove + end + + def redact(items) + items - @remove + end + end + end + + context 'redactor is set' do + let(:redactor) do + constant_redactor.new([unwanted]) + end + + before do + connection.redactor = redactor + end + + it 'does not contain the unwanted item' do + expect(connection.nodes).not_to include(unwanted) + expect(connection.nodes).not_to be_empty + end + + it 'does not redact more than once' do + expect(redactor).to receive(:redact).once.and_call_original + + connection.nodes + connection.nodes + connection.nodes + end + end +end diff --git a/spec/support/shared_examples/graphql/connection_shared_examples.rb b/spec/support/shared_examples/graphql/connection_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..4cba5b5a69dab6888c22a9be9140e6273a90a5b6 --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a connection with collection methods' do + %i[to_a size include? empty?].each do |method_name| + it "responds to #{method_name}" do + expect(connection).to respond_to(method_name) + end + end +end