From 168dcc270104cf0374f6a3ba63b768b1ae865bcb Mon Sep 17 00:00:00 2001 From: "Patrick J. Cherry" Date: Wed, 1 Mar 2023 17:03:04 +0000 Subject: [PATCH 1/4] Add Delete, Update mutations --- app/graphql/mutations/delete_project.rb | 31 +++++++ app/graphql/mutations/update_project.rb | 31 +++++++ .../types/create_project_input_type.rb | 11 +++ app/graphql/types/mutation_type.rb | 4 +- .../types/update_project_input_type.rb | 11 +++ .../mutations/delete_project_mutation_spec.rb | 75 ++++++++++++++++ .../mutations/update_project_mutation_spec.rb | 90 +++++++++++++++++++ 7 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/delete_project.rb create mode 100644 app/graphql/mutations/update_project.rb create mode 100644 app/graphql/types/create_project_input_type.rb create mode 100644 app/graphql/types/update_project_input_type.rb create mode 100644 spec/graphql/mutations/delete_project_mutation_spec.rb create mode 100644 spec/graphql/mutations/update_project_mutation_spec.rb diff --git a/app/graphql/mutations/delete_project.rb b/app/graphql/mutations/delete_project.rb new file mode 100644 index 000000000..acd9d81c6 --- /dev/null +++ b/app/graphql/mutations/delete_project.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + class DeleteProject < BaseMutation + description 'A mutation to delete an existing project' + + argument :id, String, required: true, description: 'The ID of the project to delete' + + field :id, String, 'The ID of the project that has been deleted' + + def resolve(**input) + project = GlobalID.find(input[:id]) + + raise GraphQL::ExecutionError, 'Project not found' unless project + + unless context[:current_ability].can?(:destroy, project) + raise GraphQL::ExecutionError, 'You are not permitted to delete that project' + end + + return { id: project.id } if project.destroy + + raise GraphQL::ExecutionError, "Deletion failed for project #{project.identifier}" + end + + def ready?(...) + return true if context[:current_ability]&.can?(:destroy, Project, user_id: context[:current_user_id]) + + raise GraphQL::ExecutionError, 'You are not permitted to delete projects' + end + end +end diff --git a/app/graphql/mutations/update_project.rb b/app/graphql/mutations/update_project.rb new file mode 100644 index 000000000..d9b12d0e1 --- /dev/null +++ b/app/graphql/mutations/update_project.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + class UpdateProject < BaseMutation + description 'A mutation to update an existing project' + + input_object_class Types::UpdateProjectInputType + + field :project, Types::ProjectType, description: 'The project that has been updated' + + def resolve(**input) + project = GlobalID.find(input[:id]) + raise GraphQL::ExecutionError, 'Project not found' unless project + + unless context[:current_ability].can?(:update, project) + raise GraphQL::ExecutionError, + 'You are not permitted to update this project' + end + + return { project: } if project.update(input.slice(:project_type, :name)) + + raise GraphQL::ExecutionError, project.errors.full_messages.join(", ") + end + + def ready?(**_args) + return true if context[:current_ability]&.can?(:update, Project, user_id: context[:current_user_id]) + + raise GraphQL::ExecutionError, 'You are not permitted to update a project' + end + end +end diff --git a/app/graphql/types/create_project_input_type.rb b/app/graphql/types/create_project_input_type.rb new file mode 100644 index 000000000..47f8b58b9 --- /dev/null +++ b/app/graphql/types/create_project_input_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class CreateProjectInputType < Types::BaseInputObject + description 'Represents a project during creation' + + argument :components, [Types::ProjectComponentInputType], required: false, description: 'Any project components' + argument :name, String, required: true, description: 'The name of the project' + argument :project_type, String, required: true, description: 'The type of project, e.g. python, html' + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index f40de8832..547693db6 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -2,6 +2,8 @@ module Types class MutationType < Types::BaseObject - field :create_project, mutation: Mutations::CreateProject, description: 'A mutation to create a project' + field :create_project, mutation: Mutations::CreateProject, description: 'Create a project, complete with components' + field :delete_project, mutation: Mutations::DeleteProject, description: 'Delete an existing project' + field :update_project, mutation: Mutations::UpdateProject, description: 'Update fields on an existing project' end end diff --git a/app/graphql/types/update_project_input_type.rb b/app/graphql/types/update_project_input_type.rb new file mode 100644 index 000000000..a2ab9a1da --- /dev/null +++ b/app/graphql/types/update_project_input_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class UpdateProjectInputType < Types::BaseInputObject + description 'Represents a project during an update' + + argument :id, String, required: true, description: 'The ID of the project to update' + argument :name, String, required: false, description: 'The name of the project' + argument :project_type, String, required: false, description: 'The type of project, e.g. python, html' + end +end diff --git a/spec/graphql/mutations/delete_project_mutation_spec.rb b/spec/graphql/mutations/delete_project_mutation_spec.rb new file mode 100644 index 000000000..a738d638c --- /dev/null +++ b/spec/graphql/mutations/delete_project_mutation_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'mutation DeleteProject() { ... }' do + subject(:result) { execute_query(query: mutation, variables:) } + + let(:mutation) { 'mutation DeleteProject($project: DeleteProjectInput!) { deleteProject(input: $project) { id } }' } + let(:project_id) { 'dummy-id' } + let(:variables) { { project: { id: project_id } } } + + it { expect(mutation).to be_a_valid_graphql_query } + + context 'with an existing project' do + let!(:project) { create(:project, user_id: SecureRandom.uuid) } + let(:project_id) { project.to_gid_param } + + context 'when unauthenticated' do + it 'does not delete a project' do + expect { result }.not_to change(project, :name) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_blank + end + end + + context 'when the graphql context is unset' do + let(:graphql_context) { nil } + + it 'does not delete a project' do + expect { result }.not_to change(project, :name) + end + end + + context 'when authenticated' do + let(:current_user_id) { project.user_id } + + it 'deletes the project' do + result + expect { project.reload }.to raise_exception(ActiveRecord::RecordNotFound) + end + + context 'when the project cannot be found' do + let(:project_id) { 'dummy' } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/not found/) + end + end + + + context 'with another users project' do + let(:current_user_id) { SecureRandom.uuid } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/not permitted/) + end + end + + context 'when project delete fails' do + before do + errors = instance_double(ActiveModel::Errors, full_messages: ['An error message']) + allow(project).to receive(:destroy).and_return(false) + allow(project).to receive(:errors).and_return(errors) + allow(GlobalID).to receive(:find).and_return(project) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/Deletion failed/) + end + end + end + end +end diff --git a/spec/graphql/mutations/update_project_mutation_spec.rb b/spec/graphql/mutations/update_project_mutation_spec.rb new file mode 100644 index 000000000..0d2d2e422 --- /dev/null +++ b/spec/graphql/mutations/update_project_mutation_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'mutation UpdateProject() { ... }' do + subject(:result) { execute_query(query: mutation, variables:) } + + let(:mutation) { 'mutation UpdateProject($project: UpdateProjectInput!) { updateProject(input: $project) { project { id } } }' } + let(:project_id) { 'dummy-id' } + let(:variables) do + { + project: { + id: project_id, + name: 'Untitled project again', + projectType: 'html' + } + } + end + + it { expect(mutation).to be_a_valid_graphql_query } + + context 'with an existing project' do + let(:project) { create(:project, user_id: SecureRandom.uuid, project_type: :python) } + let(:project_id) { project.to_gid_param } + + before do + # Instantiate project + project + end + + context 'when unauthenticated' do + it 'does not update a project' do + expect { result }.not_to change(project, :name) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_blank + end + end + + context 'when the graphql context is unset' do + let(:graphql_context) { nil } + + it 'does not update a project' do + expect { result }.not_to change(project, :name) + end + end + + context 'when authenticated' do + let(:current_user_id) { project.user_id } + + it 'updates the project name' do + expect { result }.to change { project.reload.name }.from(project.name).to(variables.dig(:project, :name)) + end + + it 'updates the project type' do + expect { result }.to change { project.reload.project_type }.from(project.project_type).to('html') + end + + context 'when the project cannot be found' do + let(:project_id) { 'dummy' } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/not found/) + end + end + + context 'with another users project' do + let(:current_user_id) { SecureRandom.uuid } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/not permitted/) + end + end + + context 'when project update fails' do + before do + errors = instance_double(ActiveModel::Errors, full_messages: ['An error message']) + allow(project).to receive(:save).and_return(false) + allow(project).to receive(:errors).and_return(errors) + allow(GlobalID).to receive(:find).and_return(project) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to match(/An error message/) + end + end + end + end +end From d96ed86f272b8e41a92c550663492c22c2dfd4d8 Mon Sep 17 00:00:00 2001 From: "Patrick J. Cherry" Date: Wed, 1 Mar 2023 17:04:33 +0000 Subject: [PATCH 2/4] Rubocop --- app/graphql/mutations/update_project.rb | 2 +- spec/graphql/mutations/delete_project_mutation_spec.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/graphql/mutations/update_project.rb b/app/graphql/mutations/update_project.rb index d9b12d0e1..038a62c0f 100644 --- a/app/graphql/mutations/update_project.rb +++ b/app/graphql/mutations/update_project.rb @@ -19,7 +19,7 @@ def resolve(**input) return { project: } if project.update(input.slice(:project_type, :name)) - raise GraphQL::ExecutionError, project.errors.full_messages.join(", ") + raise GraphQL::ExecutionError, project.errors.full_messages.join(', ') end def ready?(**_args) diff --git a/spec/graphql/mutations/delete_project_mutation_spec.rb b/spec/graphql/mutations/delete_project_mutation_spec.rb index a738d638c..85fd1116b 100644 --- a/spec/graphql/mutations/delete_project_mutation_spec.rb +++ b/spec/graphql/mutations/delete_project_mutation_spec.rb @@ -49,7 +49,6 @@ end end - context 'with another users project' do let(:current_user_id) { SecureRandom.uuid } From bb88a44aa3af0a693375fcfde5126f58ae2f95f7 Mon Sep 17 00:00:00 2001 From: "Patrick J. Cherry" Date: Wed, 1 Mar 2023 17:10:02 +0000 Subject: [PATCH 3/4] Refactor CreateProjectInputType and CreateProject mutation --- app/graphql/mutations/create_project.rb | 6 +- ...ype.rb => project_component_input_type.rb} | 4 +- db/schema.graphql | 139 ++++++++++++++---- 3 files changed, 114 insertions(+), 35 deletions(-) rename app/graphql/types/{component_input_type.rb => project_component_input_type.rb} (71%) diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb index 7f45ed0d5..f14db76f4 100644 --- a/app/graphql/mutations/create_project.rb +++ b/app/graphql/mutations/create_project.rb @@ -3,14 +3,10 @@ module Mutations class CreateProject < BaseMutation description 'A mutation to create a new project' + input_object_class Types::CreateProjectInputType field :project, Types::ProjectType, description: 'The project that has been created' - # rubocop:disable GraphQL/ExtractInputType - argument :components, [Types::ComponentInputType], required: false, description: 'Any project components' - argument :name, String, required: true, description: 'The name of the project' - argument :project_type, String, required: true, description: 'The type of project, e.g. python, html' - # rubocop:enable GraphQL/ExtractInputType def resolve(**input) project_hash = input.merge(user_id: context[:current_user_id], diff --git a/app/graphql/types/component_input_type.rb b/app/graphql/types/project_component_input_type.rb similarity index 71% rename from app/graphql/types/component_input_type.rb rename to app/graphql/types/project_component_input_type.rb index 08f62ecb4..dfff6dfb7 100644 --- a/app/graphql/types/component_input_type.rb +++ b/app/graphql/types/project_component_input_type.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true module Types - class ComponentInputType < Types::BaseInputObject + class ProjectComponentInputType < Types::BaseInputObject description 'Represents a project component during a mutation' argument :content, String, required: false, description: 'The text content of the component' argument :default, Boolean, required: true, description: 'If this is the default component on a project' argument :extension, String, required: true, description: 'The file extension of the component, e.g. html, csv, py' argument :name, String, required: true, description: 'The name of the file' - argument :project_id, ID, required: false, - description: 'The ID of the project this component should be assocated with' end end diff --git a/db/schema.graphql b/db/schema.graphql index 6b975a517..169f6039a 100644 --- a/db/schema.graphql +++ b/db/schema.graphql @@ -84,73 +84,73 @@ type ComponentEdge { } """ -Represents a project component during a mutation +Represents a project during creation """ -input ComponentInput { - """ - The text content of the component - """ - content: String - +input CreateProjectInput { """ - If this is the default component on a project + A unique identifier for the client performing the mutation. """ - default: Boolean! + clientMutationId: String """ - The file extension of the component, e.g. html, csv, py + Any project components """ - extension: String! + components: [ProjectComponentInput!] """ - The name of the file + The name of the project """ name: String! """ - The ID of the project this component should be assocated with + The type of project, e.g. python, html """ - projectId: ID + projectType: String! } """ -Autogenerated input type of CreateProject +Autogenerated return type of CreateProject. """ -input CreateProjectInput { +type CreateProjectPayload { """ A unique identifier for the client performing the mutation. """ clientMutationId: String """ - Any project components + The project that has been created """ - components: [ComponentInput!] + project: Project +} +""" +Autogenerated input type of DeleteProject +""" +input DeleteProjectInput { """ - The name of the project + A unique identifier for the client performing the mutation. """ - name: String! + clientMutationId: String """ - The type of project, e.g. python, html + The ID of the project to delete """ - projectType: String! + id: String! } """ -Autogenerated return type of CreateProject. +Autogenerated return type of DeleteProject. """ -type CreateProjectPayload { +type DeleteProjectPayload { """ A unique identifier for the client performing the mutation. """ clientMutationId: String """ - The project that has been created + The ID of the project that has been deleted """ - project: Project + id: String } """ @@ -220,7 +220,7 @@ type ImageEdge { type Mutation { """ - A mutation to create a project + Create a project, complete with components """ createProject( """ @@ -228,6 +228,26 @@ type Mutation { """ input: CreateProjectInput! ): CreateProjectPayload + + """ + Delete an existing project + """ + deleteProject( + """ + Parameters for DeleteProject + """ + input: DeleteProjectInput! + ): DeleteProjectPayload + + """ + Update fields on an existing project + """ + updateProject( + """ + Parameters for UpdateProject + """ + input: UpdateProjectInput! + ): UpdateProjectPayload } """ @@ -360,6 +380,31 @@ type Project implements Node { userId: Uuid } +""" +Represents a project component during a mutation +""" +input ProjectComponentInput { + """ + The text content of the component + """ + content: String + + """ + If this is the default component on a project + """ + default: Boolean! + + """ + The file extension of the component, e.g. html, csv, py + """ + extension: String! + + """ + The name of the file + """ + name: String! +} + """ The connection type for Project. """ @@ -462,6 +507,46 @@ type Query { ): ProjectConnection } +""" +Represents a project during an update +""" +input UpdateProjectInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the project to update + """ + id: String! + + """ + The name of the project + """ + name: String + + """ + The type of project, e.g. python, html + """ + projectType: String +} + +""" +Autogenerated return type of UpdateProject. +""" +type UpdateProjectPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The project that has been updated + """ + project: Project +} + """ A globally unique ID """ From 3183caa774125e3f9844e50abca1b52edaca07c3 Mon Sep 17 00:00:00 2001 From: "Patrick J. Cherry" Date: Wed, 1 Mar 2023 17:10:37 +0000 Subject: [PATCH 4/4] Rubocop. again --- app/graphql/mutations/create_project.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb index f14db76f4..ea3419fba 100644 --- a/app/graphql/mutations/create_project.rb +++ b/app/graphql/mutations/create_project.rb @@ -7,7 +7,6 @@ class CreateProject < BaseMutation field :project, Types::ProjectType, description: 'The project that has been created' - def resolve(**input) project_hash = input.merge(user_id: context[:current_user_id], components: input[:components]&.map(&:to_h))