From b3bc4ebc21037766c4239cbb8378246768a55cc5 Mon Sep 17 00:00:00 2001
From: Avielle Wolfe <awolfe@gitlab.com>
Date: Tue, 14 Mar 2023 18:36:14 +0100
Subject: [PATCH] Add GraphQL fields for Catalog resources

This commit adds `Ci::Catalog::ResourceType` and a `ciCatalogResources`
field to `QueryType`. All fields are `alpha` because CI Catalog
features are subject to rapid iteration.
---
 app/graphql/types/ci/catalog/resource_type.rb | 27 ++++++
 app/models/ci/catalog/resource.rb             |  2 +
 doc/api/graphql/reference/index.md            | 55 +++++++++++++
 ee/app/graphql/ee/types/query_type.rb         |  7 ++
 .../ci/catalog/resources_resolver.rb          | 32 ++++++++
 .../ci/catalog/resources_resolver_spec.rb     | 47 +++++++++++
 ee/spec/graphql/types/query_type_spec.rb      |  1 +
 .../api/graphql/ci/catalog/resources_spec.rb  | 82 +++++++++++++++++++
 .../types/ci/catalog/resource_type_spec.rb    | 18 ++++
 spec/models/ci/catalog/resource_spec.rb       |  6 ++
 10 files changed, 277 insertions(+)
 create mode 100644 app/graphql/types/ci/catalog/resource_type.rb
 create mode 100644 ee/app/graphql/resolvers/ci/catalog/resources_resolver.rb
 create mode 100644 ee/spec/graphql/resolvers/ci/catalog/resources_resolver_spec.rb
 create mode 100644 ee/spec/requests/api/graphql/ci/catalog/resources_spec.rb
 create mode 100644 spec/graphql/types/ci/catalog/resource_type_spec.rb

diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb
new file mode 100644
index 0000000000000..b5947826fa168
--- /dev/null
+++ b/app/graphql/types/ci/catalog/resource_type.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Types
+  module Ci
+    module Catalog
+      # rubocop: disable Graphql/AuthorizeTypes
+      class ResourceType < BaseObject
+        graphql_name 'CiCatalogResource'
+
+        connection_type_class(Types::CountableConnectionType)
+
+        field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.',
+          alpha: { milestone: '15.11' }
+
+        field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.',
+          alpha: { milestone: '15.11' }
+
+        field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.',
+          alpha: { milestone: '15.11' }
+
+        field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.',
+          method: :avatar_path, alpha: { milestone: '15.11' }
+      end
+      # rubocop: enable Graphql/AuthorizeTypes
+    end
+  end
+end
diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb
index 40ec41de6feee..837f1352b4db7 100644
--- a/app/models/ci/catalog/resource.rb
+++ b/app/models/ci/catalog/resource.rb
@@ -13,6 +13,8 @@ class Resource < ::ApplicationRecord
       belongs_to :project
 
       scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
+
+      delegate :avatar_path, :description, :name, to: :project
     end
   end
 end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 539f1c1d33f7f..58fe354093731 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -55,6 +55,26 @@ CI related settings that apply to the entire instance.
 
 Returns [`CiApplicationSettings`](#ciapplicationsettings).
 
+### `Query.ciCatalogResources`
+
+CI Catalog resources visible to the current user.
+
+WARNING:
+**Introduced** in 15.11.
+This feature is in Alpha. It can be changed or removed at any time.
+
+Returns [`CiCatalogResourceConnection`](#cicatalogresourceconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="querycicatalogresourcesprojectpath"></a>`projectPath` | [`ID`](#id) | Project with the namespace catalog. |
+
 ### `Query.ciConfig`
 
 Linted and processed contents of a CI config.
@@ -7113,6 +7133,30 @@ The edge type for [`CiBuildNeed`](#cibuildneed).
 | <a id="cibuildneededgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
 | <a id="cibuildneededgenode"></a>`node` | [`CiBuildNeed`](#cibuildneed) | The item at the end of the edge. |
 
+#### `CiCatalogResourceConnection`
+
+The connection type for [`CiCatalogResource`](#cicatalogresource).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cicatalogresourceconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. |
+| <a id="cicatalogresourceconnectionedges"></a>`edges` | [`[CiCatalogResourceEdge]`](#cicatalogresourceedge) | A list of edges. |
+| <a id="cicatalogresourceconnectionnodes"></a>`nodes` | [`[CiCatalogResource]`](#cicatalogresource) | A list of nodes. |
+| <a id="cicatalogresourceconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `CiCatalogResourceEdge`
+
+The edge type for [`CiCatalogResource`](#cicatalogresource).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cicatalogresourceedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="cicatalogresourceedgenode"></a>`node` | [`CiCatalogResource`](#cicatalogresource) | The item at the end of the edge. |
+
 #### `CiConfigGroupConnection`
 
 The connection type for [`CiConfigGroup`](#ciconfiggroup).
@@ -11677,6 +11721,17 @@ Represents the total number of issues and their weights for a particular day.
 | <a id="cibuildneedid"></a>`id` | [`ID!`](#id) | ID of the BuildNeed. |
 | <a id="cibuildneedname"></a>`name` | [`String`](#string) | Name of the job we need to complete. |
 
+### `CiCatalogResource`
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cicatalogresourcedescription"></a>`description` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is in Alpha. It can be changed or removed at any time. Description of the catalog resource. |
+| <a id="cicatalogresourceicon"></a>`icon` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is in Alpha. It can be changed or removed at any time. Icon for the catalog resource. |
+| <a id="cicatalogresourceid"></a>`id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 15.11. This feature is in Alpha. It can be changed or removed at any time. ID of the catalog resource. |
+| <a id="cicatalogresourcename"></a>`name` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is in Alpha. It can be changed or removed at any time. Name of the catalog resource. |
+
 ### `CiConfig`
 
 #### Fields
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index 7096067428350..ecf2f6caaaf54 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -68,6 +68,13 @@ module QueryType
                   required: true,
                   description: 'Global ID of the Vulnerability.'
               end
+
+        field :ci_catalog_resources,
+              ::Types::Ci::Catalog::ResourceType.connection_type,
+              null: true,
+              alpha: { milestone: '15.11' },
+              description: 'CI Catalog resources visible to the current user',
+              resolver: ::Resolvers::Ci::Catalog::ResourcesResolver
       end
 
       def vulnerability(id:)
diff --git a/ee/app/graphql/resolvers/ci/catalog/resources_resolver.rb b/ee/app/graphql/resolvers/ci/catalog/resources_resolver.rb
new file mode 100644
index 0000000000000..c233ab6e41244
--- /dev/null
+++ b/ee/app/graphql/resolvers/ci/catalog/resources_resolver.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Resolvers
+  module Ci
+    module Catalog
+      class ResourcesResolver < BaseResolver
+        include Gitlab::Graphql::Authorize::AuthorizeResource
+        include ResolvesProject
+
+        authorize :read_namespace_catalog
+
+        type ::Types::Ci::Catalog::ResourceType.connection_type, null: true
+
+        argument :project_path, GraphQL::Types::ID,
+          required: false,
+          description: 'Project with the namespace catalog.'
+
+        def resolve(project_path:)
+          project = authorized_find!(project_path: project_path)
+
+          ::Ci::Catalog::Listing.new(project.root_namespace, context[:current_user]).resources
+        end
+
+        private
+
+        def find_object(project_path:)
+          resolve_project(full_path: project_path)
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/graphql/resolvers/ci/catalog/resources_resolver_spec.rb b/ee/spec/graphql/resolvers/ci/catalog/resources_resolver_spec.rb
new file mode 100644
index 0000000000000..15888f9593fbd
--- /dev/null
+++ b/ee/spec/graphql/resolvers/ci/catalog/resources_resolver_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::Catalog::ResourcesResolver, feature_category: :pipeline_composition do
+  include GraphqlHelpers
+
+  let_it_be(:namespace) { create(:group) }
+  let_it_be(:project_1) { create(:project, name: 'Component Repository 1', namespace: namespace) }
+  let_it_be(:project_2) { create(:project, name: 'Component Repository 2', namespace: namespace) }
+  let_it_be(:resource_1) { create(:catalog_resource, project: project_1) }
+  let_it_be(:resource_2) { create(:catalog_resource, project: project_2) }
+  let_it_be(:user) { create(:user) }
+
+  describe '#resolve' do
+    it 'returns all CI Catalog resources visible to the current user in the namespace' do
+      stub_licensed_features(ci_namespace_catalog: true)
+      namespace.add_owner(user)
+
+      result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
+
+      expect(result.items.count).to be(2)
+      expect(result.items.pluck(:name)).to contain_exactly('Component Repository 1', 'Component Repository 2')
+    end
+
+    context 'when the current user cannot read the namespace catalog' do
+      it 'raises ResourceNotAvailable' do
+        stub_licensed_features(ci_namespace_catalog: true)
+        namespace.add_guest(user)
+
+        result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
+
+        expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
+      end
+    end
+
+    context 'when the namespace catalog feature is not available' do
+      it 'raises ResourceNotAvailable' do
+        namespace.add_owner(user)
+
+        result = resolve(described_class, ctx: { current_user: user }, args: { project_path: project_1.full_path })
+
+        expect(result).to be_a(::Gitlab::Graphql::Errors::ResourceNotAvailable)
+      end
+    end
+  end
+end
diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb
index 4ce45073515ba..4ec8b477e4463 100644
--- a/ee/spec/graphql/types/query_type_spec.rb
+++ b/ee/spec/graphql/types/query_type_spec.rb
@@ -7,6 +7,7 @@
 
   specify do
     expected_ee_fields = [
+      :ci_catalog_resources,
       :ci_minutes_usage,
       :current_license,
       :devops_adoption_enabled_namespaces,
diff --git a/ee/spec/requests/api/graphql/ci/catalog/resources_spec.rb b/ee/spec/requests/api/graphql/ci/catalog/resources_spec.rb
new file mode 100644
index 0000000000000..95b2090be247c
--- /dev/null
+++ b/ee/spec/requests/api/graphql/ci/catalog/resources_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_composition do
+  include GraphqlHelpers
+
+  let_it_be(:namespace) { create(:group) }
+  let_it_be(:project_2) { create(:project, namespace: namespace) }
+  let_it_be(:resource_2) { create(:catalog_resource, project: project_2) }
+  let_it_be(:user) { create(:user) }
+
+  let_it_be(:project_1) do
+    create(
+      :project, :with_avatar,
+      name: 'Component Repository',
+      description: 'A simple component',
+      namespace: namespace
+    )
+  end
+
+  let_it_be(:resource_1) { create(:catalog_resource, project: project_1) }
+
+  let(:query) do
+    %(
+      query {
+        ciCatalogResources(projectPath: "#{project_2.full_path}") {
+          count
+
+          nodes {
+            name
+            description
+            icon
+          }
+        }
+      }
+    )
+  end
+
+  context 'when the CI Namespace Catalog feature is available' do
+    before do
+      stub_licensed_features(ci_namespace_catalog: true)
+    end
+
+    it 'returns all resources visible to the current user in the namespace' do
+      namespace.add_developer(user)
+
+      post_graphql(query, current_user: user)
+
+      resources_data = graphql_data['ciCatalogResources']
+      expect(resources_data['count']).to be(2)
+      expect(resources_data['nodes'].count).to be(2)
+
+      resource_1_data = resources_data['nodes'].first
+      expect(resource_1_data['name']).to eq('Component Repository')
+      expect(resource_1_data['description']).to eq('A simple component')
+      expect(resource_1_data['icon']).to eq(project_1.avatar_path)
+    end
+
+    context 'when the current user does not have permission to read the namespace catalog' do
+      it 'returns an empty array' do
+        namespace.add_guest(user)
+
+        post_graphql(query, current_user: user)
+
+        resources_data = graphql_data['ciCatalogResources']
+        expect(resources_data).to be_nil
+      end
+    end
+  end
+
+  context 'when the CI Namespace Catalog feature is not available' do
+    it 'returns an empty array' do
+      namespace.add_developer(user)
+
+      post_graphql(query, current_user: user)
+
+      resources_data = graphql_data['ciCatalogResources']
+      expect(resources_data).to be_nil
+    end
+  end
+end
diff --git a/spec/graphql/types/ci/catalog/resource_type_spec.rb b/spec/graphql/types/ci/catalog/resource_type_spec.rb
new file mode 100644
index 0000000000000..d0bb45a4f1d43
--- /dev/null
+++ b/spec/graphql/types/ci/catalog/resource_type_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Ci::Catalog::ResourceType, feature_category: :pipeline_composition do
+  specify { expect(described_class.graphql_name).to eq('CiCatalogResource') }
+
+  it 'exposes the expected fields' do
+    expected_fields = %i[
+      id
+      name
+      description
+      icon
+    ]
+
+    expect(described_class).to have_graphql_fields(*expected_fields)
+  end
+end
diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb
index 78656c743eddf..1a699fcd842ae 100644
--- a/spec/models/ci/catalog/resource_spec.rb
+++ b/spec/models/ci/catalog/resource_spec.rb
@@ -3,6 +3,12 @@
 require 'spec_helper'
 
 RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do
+  it { is_expected.to belong_to(:project) }
+
+  it { is_expected.to delegate_method(:avatar_path).to(:project) }
+  it { is_expected.to delegate_method(:description).to(:project) }
+  it { is_expected.to delegate_method(:name).to(:project) }
+
   describe '.for_projects' do
     it 'returns catalog resources for the given project IDs' do
       project = create(:project)
-- 
GitLab