diff --git a/docs/api/apiv3/components/examples/storage-create-folder-request-body.yml b/docs/api/apiv3/components/examples/storage-create-folder-request-body.yml new file mode 100644 index 000000000000..dad2d8b2e047 --- /dev/null +++ b/docs/api/apiv3/components/examples/storage-create-folder-request-body.yml @@ -0,0 +1,5 @@ +description: |- + A valid request body to create a new folder on a external storage +value: + name: Uploads + parentId: "200" diff --git a/docs/api/apiv3/components/schemas/storage_folder_write_model.yml b/docs/api/apiv3/components/schemas/storage_folder_write_model.yml new file mode 100644 index 000000000000..bcdef5c9e552 --- /dev/null +++ b/docs/api/apiv3/components/schemas/storage_folder_write_model.yml @@ -0,0 +1,13 @@ +# Schema: StorageFolderWriteModel +--- +type: object +required: + - name + - parentId +properties: + name: + type: string + description: Name of the folder to be created + parentId: + type: string + description: Unique identifier of the parent folder in which the new folder should be created in diff --git a/docs/api/apiv3/openapi-spec.yml b/docs/api/apiv3/openapi-spec.yml index e3afad89a8f4..7cba8c52b76d 100644 --- a/docs/api/apiv3/openapi-spec.yml +++ b/docs/api/apiv3/openapi-spec.yml @@ -392,6 +392,8 @@ paths: "$ref": "./paths/storage_files.yml" "/api/v3/storages/{id}/files/prepare_upload": "$ref": "./paths/storage_files_prepare_upload.yml" + "/api/v3/storages/{id}/folders": + "$ref": "./paths/storage_folders.yml" "/api/v3/storages/{id}/oauth_client_credentials": "$ref": "./paths/storage_oauth_client_credentials.yml" "/api/v3/storages/{id}/open": @@ -545,6 +547,8 @@ components: $ref: "./components/examples/status_response.yml" StorageNextcloudResponse: $ref: "./components/examples/storage-nextcloud-response.yml" + StorageCreateFolderRequestBody: + $ref: "./components/examples/storage-create-folder-request-body.yml" StorageNextcloudResponseForCreation: $ref: "./components/examples/storage-nextcloud-response-for-creation.yml" StorageNextcloudUnauthorizedResponse: @@ -825,6 +829,8 @@ components: "$ref": "./components/schemas/storage_file_model.yml" StorageFilesModel: "$ref": "./components/schemas/storage_files_model.yml" + StorageFolderWriteModel: + "$ref": "./components/schemas/storage_folder_write_model.yml" StorageFileUploadPreparationModel: "$ref": "./components/schemas/storage_file_upload_preparation_model.yml" StorageFileUploadLinkModel: diff --git a/docs/api/apiv3/paths/storage_folders.yml b/docs/api/apiv3/paths/storage_folders.yml new file mode 100644 index 000000000000..1e22c776775f --- /dev/null +++ b/docs/api/apiv3/paths/storage_folders.yml @@ -0,0 +1,68 @@ +# /api/v3/storages/{id}/folders +--- +post: + summary: Creation of a new folder + operationId: create_storage_folder + tags: + - File links + description: Creates a new folder under the given parent + parameters: + - name: id + description: Storage id + in: path + required: true + schema: + type: integer + example: 1337 + requestBody: + content: + application/json: + schema: + $ref: '../components/schemas/storage_folder_write_model.yml' + examples: + 'Valid example': + $ref: '../components/examples/storage-create-folder-request-body.yml' + responses: + '201': + description: Created + content: + application/hal+json: + schema: + $ref: '../components/schemas/storage_file_model.yml' + '400': + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:InvalidQuery + message: The given parent is not a directory. + description: |- + Returned if the request is missing a required parameter. + '403': + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission + message: You are not authorized to access this resource. + description: |- + Returned if the client does not have sufficient permissions. + + **Required permission:** manage file links + '404': + content: + application/hal+json: + schema: + $ref: '../components/schemas/error_response.yml' + example: + _type: Error + errorIdentifier: urn:openproject-org:api:v3:errors:NotFound + message: The requested resource could not be found. + description: |- + Returned if the storage does not exist or the client does not have sufficient permissions to see it. + + **Required permission:** view file links diff --git a/modules/storages/app/services/storages/create_folder_service.rb b/modules/storages/app/services/storages/create_folder_service.rb new file mode 100644 index 000000000000..8c9f2ed1d352 --- /dev/null +++ b/modules/storages/app/services/storages/create_folder_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Storages + class CreateFolderService < BaseService + using Peripherals::ServiceResultRefinements + + def self.call(storage:, user:, name:, parent_id:) + new.call(storage:, user:, name:, parent_id:) + end + + def call(storage:, user:, name:, parent_id:) + auth_strategy = Peripherals::Registry.resolve("#{storage}.authentication.user_bound").call(user: user) + + Peripherals::Registry + .resolve("#{storage}.commands.create_folder") + .call( + storage:, + auth_strategy:, + folder_name: name, + parent_location: parent_path(parent_id, storage, user) + ) + end + + private + + def parent_path(parent_id, storage, user) + case storage.short_provider_type + when "nextcloud" + location_from_file_info(parent_id, storage, user) + when "one_drive" + Peripherals::ParentFolder.new(parent_id) + else + raise "Unknown Storage Type" + end + end + + def location_from_file_info(parent_id, storage, user) + StorageFileService + .call(storage: storage, user: user, file_id: parent_id) + .match( + on_success: lambda { |folder_info| + path = URI.decode_uri_component(folder_info.location) + Peripherals::ParentFolder.new(path) + }, + on_failure: ->(error) { raise error } + ) + end + end +end diff --git a/modules/storages/lib/api/v3/storage_files/storage_folders_api.rb b/modules/storages/lib/api/v3/storage_files/storage_folders_api.rb new file mode 100644 index 000000000000..9766923a5e05 --- /dev/null +++ b/modules/storages/lib/api/v3/storage_files/storage_folders_api.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module API + module V3 + module StorageFiles + class StorageFoldersAPI < ::API::OpenProjectAPI + using ::Storages::Peripherals::ServiceResultRefinements + + helpers ::Storages::Peripherals::StorageErrorHelper + + resources :folders do + params do + requires :name, type: String, desc: "Folder name" + requires :parent_id, type: String, desc: "Id of the parent folder" + end + + post do + ::Storages::CreateFolderService.call( + storage: @storage, + user: current_user, + name: params["name"], + parent_id: params["parent_id"] + ).match( + on_success: lambda { |storage_folder| + API::V3::StorageFiles::StorageFileRepresenter.new( + storage_folder, + @storage, + current_user: + ) + }, + on_failure: ->(error) { raise_error(error) } + ) + end + end + end + end + end +end diff --git a/modules/storages/lib/api/v3/storages/storages_api.rb b/modules/storages/lib/api/v3/storages/storages_api.rb index b5dedbcd20e7..f77b67fa4d24 100644 --- a/modules/storages/lib/api/v3/storages/storages_api.rb +++ b/modules/storages/lib/api/v3/storages/storages_api.rb @@ -50,6 +50,7 @@ class API::V3::Storages::StoragesAPI < API::OpenProjectAPI mount API::V3::StorageFiles::StorageFilesAPI mount API::V3::OAuthClient::OAuthClientCredentialsAPI mount API::V3::Storages::StorageOpenAPI + mount API::V3::StorageFiles::StorageFoldersAPI end end end diff --git a/modules/storages/lib/open_project/storages/engine.rb b/modules/storages/lib/open_project/storages/engine.rb index 1a40a918da0b..d60979349731 100644 --- a/modules/storages/lib/open_project/storages/engine.rb +++ b/modules/storages/lib/open_project/storages/engine.rb @@ -312,6 +312,10 @@ def self.external_file_permissions "#{storage_files(storage_id)}/#{file_id}" end + add_api_path :storage_folders do |storage_id| + "#{storage(storage_id)}/folders" + end + add_api_path :prepare_upload do |storage_id| "#{storage(storage_id)}/files/prepare_upload" end diff --git a/modules/storages/spec/requests/api/v3/storages/storage_folders_api_spec.rb b/modules/storages/spec/requests/api/v3/storages/storage_folders_api_spec.rb new file mode 100644 index 000000000000..fddfa2a9bdcb --- /dev/null +++ b/modules/storages/spec/requests/api/v3/storages/storage_folders_api_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe "API v3 storage folders", :webmock, content_type: :json do + include API::V3::Utilities::PathHelper + include StorageServerHelpers + + let(:permissions) { %i(view_work_packages view_file_links manage_file_links) } + let(:project) { create(:project) } + + let(:current_user) do + create(:user, member_with_permissions: { project => permissions }) + end + + let(:oauth_application) { create(:oauth_application) } + let(:storage) { create(:nextcloud_storage_configured, creator: current_user, oauth_application:) } + let(:oauth_token) { create(:oauth_client_token, user: current_user, oauth_client: storage.oauth_client) } + let(:project_storage) { create(:project_storage, project:, storage:) } + + subject(:last_response) { post(path, body) } + + before do + oauth_application + project_storage + login_as current_user + end + + describe "POST /api/v3/storages/:storage_id/folders" do + let(:path) { api_v3_paths.storage_folders(storage.id) } + let(:body) { { parent_id: file_info.id, name: folder_name }.to_json } + let(:folder_name) { "TestFolder" } + + let(:response) do + Storages::StorageFile.new( + id: 1, + name: folder_name, + size: 128, + mime_type: "application/x-op-directory", + created_at: DateTime.now, + last_modified_at: DateTime.now, + created_by_name: "Obi-Wan Kenobi", + last_modified_by_name: "Obi-Wan Kenobi", + location: "/", + permissions: %i[readable] + ) + end + + let(:file_info) do + Storages::StorageFileInfo.new( + status: "OK", + status_code: 200, + id: SecureRandom.hex, + name: "/", + location: "/" + ) + end + + before do + file_info_mock = class_double(Storages::Peripherals::StorageInteraction::Nextcloud::FileInfoQuery) + allow(file_info_mock).to receive(:call).with( + storage: storage, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + file_id: file_info.id + ).and_return(ServiceResult.success(result: file_info)) + Storages::Peripherals::Registry.stub("nextcloud.queries.file_info", file_info_mock) + end + + context "with successful response" do + subject { last_response.body } + + before do + create_folder_mock = class_double(Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand) + allow(create_folder_mock).to receive(:call).with( + storage: storage, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + folder_name:, + parent_location: instance_of(Storages::Peripherals::ParentFolder) + ).and_return(ServiceResult.success(result: response)) + Storages::Peripherals::Registry.stub("nextcloud.commands.create_folder", create_folder_mock) + end + + it "responds with appropriate JSON" do + expect(subject).to be_json_eql(response.id.to_json).at_path("id") + expect(subject).to be_json_eql(response.name.to_json).at_path("name") + expect(subject).to be_json_eql(response.permissions.to_json).at_path("permissions") + end + end + + context "with query failed" do + before do + create_folder_mock = class_double(Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand) + allow(create_folder_mock).to receive(:call).with( + storage: storage, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + folder_name:, + parent_location: instance_of(Storages::Peripherals::ParentFolder) + ).and_return(ServiceResult.failure(result: error, errors: Storages::StorageError.new(code: error))) + Storages::Peripherals::Registry.stub("nextcloud.commands.create_folder", create_folder_mock) + end + + context "with authorization failure" do + let(:error) { :unauthorized } + + it { expect(last_response).to have_http_status(:internal_server_error) } + end + + context "with internal error" do + let(:error) { :error } + + it { expect(last_response).to have_http_status(:internal_server_error) } + end + end + end +end diff --git a/modules/storages/spec/services/storages/create_folder_service_spec.rb b/modules/storages/spec/services/storages/create_folder_service_spec.rb new file mode 100644 index 000000000000..d4c6a96ce2d0 --- /dev/null +++ b/modules/storages/spec/services/storages/create_folder_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe Storages::CreateFolderService do + subject(:service) { described_class.call(storage:, user:, name:, parent_id:) } + + let(:user) { create(:admin) } + let(:name) { "TestFolderName" } + + context "when storage is nextcloud" do + let(:storage) { create(:nextcloud_storage) } + let(:parent_id) { file_info.id } + + let(:file_info) do + Storages::StorageFileInfo.new( + status: "OK", + status_code: 200, + id: SecureRandom.hex, + name: "/", + location: "/Path/To/Parent/Next" + ) + end + + let(:create_folder_command) { class_double(Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand) } + + before do + file_info_mock = class_double(Storages::Peripherals::StorageInteraction::Nextcloud::FileInfoQuery) + allow(file_info_mock).to receive(:call).with( + storage: storage, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + file_id: file_info.id + ).and_return(ServiceResult.success(result: file_info)) + Storages::Peripherals::Registry.stub("nextcloud.queries.file_info", file_info_mock) + + allow(create_folder_command).to receive(:call).and_return(ServiceResult.success) + Storages::Peripherals::Registry.stub("nextcloud.commands.create_folder", create_folder_command) + end + + it "calls the appropriate command with the expected parameters" do + service + + expect(create_folder_command).to have_received(:call).with( + storage:, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + folder_name: name, + parent_location: Storages::Peripherals::ParentFolder.new(file_info.location) + ).once + end + end + + context "when storage is one_drive" do + let(:storage) { create(:one_drive_storage) } + let(:parent_id) { file_info.id } + + let(:file_info) do + Storages::StorageFileInfo.new( + status: "OK", + status_code: 200, + id: "/Path/To/Parent/One", + name: "/" + ) + end + + let(:create_folder_command) { class_double(Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand) } + + before do + allow(create_folder_command).to receive(:call).and_return(ServiceResult.success) + Storages::Peripherals::Registry.stub("one_drive.commands.create_folder", create_folder_command) + end + + it "calls the appropriate command with the expected parameters" do + service + + expect(create_folder_command).to have_received(:call).with( + storage:, + auth_strategy: instance_of(Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Strategy), + folder_name: name, + parent_location: Storages::Peripherals::ParentFolder.new(file_info.id) + ).once + end + end +end