Skip to content

Commit d3904d7

Browse files
authored
Merge pull request #21427 from opf/implementation/69506-create-a-sharepoint-upload-file-command-on-openproject
[69506] Create a sharepoint upload file command on OpenProject
2 parents 585f2f8 + 72e638c commit d3904d7

File tree

13 files changed

+1338
-15
lines changed

13 files changed

+1338
-15
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module Storages
32+
module Adapters
33+
module Providers
34+
module Sharepoint
35+
module Commands
36+
class UploadFileCommand < Base
37+
def call(auth_strategy:, input_data:)
38+
with_tagged_logger do
39+
drive_id, location = get_location(input_data.parent_location)
40+
info "Uploading file #{input_data.file_name} to parent location #{input_data.parent_location}"
41+
file_content = input_data.io.read
42+
43+
Authentication[auth_strategy].call(storage: @storage) do |http|
44+
upload_file(http, auth_strategy, drive_id, location, input_data.file_name, file_content)
45+
end
46+
end
47+
end
48+
49+
private
50+
51+
def get_location(parent_location)
52+
split_identifier(parent_location) => { drive_id:, location: }
53+
[drive_id, location]
54+
end
55+
56+
def upload_file(http, auth_strategy, drive_id, location, file_name, file_content)
57+
create_upload_session(auth_strategy, drive_id, location, file_name).bind do |upload_url|
58+
upload_file_content(http, upload_url, file_content).bind do |upload_response|
59+
info "File successfully uploaded, fetching its file info back..."
60+
fetch_file_info(http, drive_id, upload_response)
61+
end
62+
end
63+
end
64+
65+
def create_upload_session(auth_strategy, drive_id, location, file_name)
66+
upload_link_input = Input::UploadLink.build(
67+
folder_id: composite_folder_id(drive_id, location),
68+
file_name:
69+
).value_or { |error| return Failure(error) }
70+
71+
upload_link_query.call(auth_strategy:, input_data: upload_link_input).fmap(&:destination)
72+
end
73+
74+
def upload_file_content(http, upload_url, file_content)
75+
file_size = file_content.bytesize
76+
content_range_header = file_size.zero? ? "bytes 0-0/0" : "bytes 0-#{file_size - 1}/#{file_size}"
77+
78+
handle_response(
79+
http.put(upload_url, body: file_content, headers: { "Content-Range" => content_range_header })
80+
)
81+
end
82+
83+
def fetch_file_info(http, drive_id, upload_response)
84+
file_id = upload_response[:id]
85+
86+
return Failure(Results::Error.new(source: self.class, payload: upload_response, code: :error)) if file_id.blank?
87+
88+
item_id = Peripherals::ParentFolder.new(file_id)
89+
drive_item_query.call(http:, drive_id:, item_id:, fields: Queries::FileInfoQuery::FIELDS)
90+
.bind { |json| storage_file_transformer.transform(json) }
91+
end
92+
93+
def composite_folder_id(drive_id, location)
94+
item_id = location.root? ? nil : location
95+
"#{drive_id}#{SharepointStorage::IDENTIFIER_SEPARATOR}#{item_id}"
96+
end
97+
98+
def handle_response(response)
99+
error = Results::Error.new(source: self.class, payload: response)
100+
101+
case response
102+
in { status: 200..299 }
103+
Success(response.json(symbolize_keys: true))
104+
in { status: 401 }
105+
Failure(error.with(code: :unauthorized))
106+
in { status: 403 }
107+
Failure(error.with(code: :forbidden))
108+
in { status: 404 }
109+
Failure(error.with(code: :not_found))
110+
in { status: 409 }
111+
Failure(error.with(code: :conflict))
112+
else
113+
Failure(error.with(code: :error))
114+
end
115+
end
116+
117+
def upload_link_query
118+
@upload_link_query ||= Queries::UploadLinkQuery.new(@storage)
119+
end
120+
121+
def drive_item_query
122+
@drive_item_query ||= Queries::Internal::DriveItemQuery.new(@storage)
123+
end
124+
125+
def storage_file_transformer
126+
@storage_file_transformer ||= StorageFileTransformer.new(site_name)
127+
end
128+
end
129+
end
130+
end
131+
end
132+
end
133+
end

modules/storages/app/common/storages/adapters/providers/sharepoint/sharepoint_registry.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ module Sharepoint
4545
register(:create_list, Commands::CreateListCommand)
4646
register(:set_permissions, Commands::SetPermissionsCommand)
4747
register(:copy_template_folder, Commands::CopyTemplateFolderCommand)
48+
register(:upload_file, Commands::UploadFileCommand)
4849
end
4950

5051
namespace("components") do

modules/storages/spec/common/storages/adapters/providers/sharepoint/commands/create_folder_command_spec.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,31 +46,35 @@ module Commands
4646

4747
context "when creating a folder in the root", vcr: "sharepoint/create_folder_root" do
4848
let(:folder_name) { "Földer CreatedBy Çommand" }
49-
let(:parent_location) { composite_identifier(nil) }
49+
let(:parent_location) { SharepointSpecHelper.composite_identifier(base_drive, nil) }
5050
let(:path) { "/Marcello%20VCR/F%C3%B6lder%20CreatedBy%20%C3%87ommand" }
5151

5252
it_behaves_like "adapter create_folder_command: successful folder creation"
5353
end
5454

5555
context "when creating a folder in a parent folder", vcr: "sharepoint/create_folder_parent" do
5656
let(:folder_name) { "Földer CreatedBy Çommand" }
57-
let(:parent_location) { composite_identifier("01ANJ53W7TITEF4WCHRBDKR7VMNUWZ33WD") }
57+
let(:parent_location) do
58+
SharepointSpecHelper.composite_identifier(base_drive, "01ANJ53W7TITEF4WCHRBDKR7VMNUWZ33WD")
59+
end
5860
let(:path) { "/Marcello%20VCR/Folder%20with%20spaces/F%C3%B6lder%20CreatedBy%20%C3%87ommand" }
5961

6062
it_behaves_like "adapter create_folder_command: successful folder creation"
6163
end
6264

6365
context "when creating a folder in a non-existing parent folder", vcr: "sharepoint/create_folder_parent_not_found" do
6466
let(:folder_name) { "Földer CreatedBy Çommand" }
65-
let(:parent_location) { composite_identifier("01AZJL5PKU2WV3U3RKKFF4A7ZCWVBXRTEU") }
67+
let(:parent_location) do
68+
SharepointSpecHelper.composite_identifier(base_drive, "01AZJL5PKU2WV3U3RKKFF4A7ZCWVBXRTEU")
69+
end
6670
let(:error_source) { described_class }
6771

6872
it_behaves_like "storage adapter: error response", :not_found
6973
end
7074

7175
context "when folder already exists", vcr: "sharepoint/create_folder_already_exists" do
7276
let(:folder_name) { "data" }
73-
let(:parent_location) { composite_identifier(nil) }
77+
let(:parent_location) { SharepointSpecHelper.composite_identifier(base_drive, nil) }
7478
let(:error_source) { described_class }
7579

7680
it_behaves_like "storage adapter: error response", :conflict
@@ -94,8 +98,6 @@ module Commands
9498

9599
private
96100

97-
def composite_identifier(item_id) = "#{base_drive}#{SharepointStorage::IDENTIFIER_SEPARATOR}#{item_id}"
98-
99101
def delete_created_folder(folder)
100102
Input::DeleteFolder.build(location: folder.id).bind do |input_data|
101103
Registry.resolve("sharepoint.commands.delete_folder").call(storage:, auth_strategy:, input_data:)

modules/storages/spec/common/storages/adapters/providers/sharepoint/commands/delete_folder_command_spec.rb

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ module Commands
5353
end
5454

5555
it "deletes a folder", vcr: "sharepoint/delete_folder" do
56-
create_result = Input::CreateFolder
57-
.build(folder_name: "To Be Deleted Soon", parent_location: composite_identifier(nil))
58-
.bind do |input_data|
56+
create_result = Input::CreateFolder.build(
57+
folder_name: "To Be Deleted Soon",
58+
parent_location: SharepointSpecHelper.composite_identifier(base_drive, nil)
59+
).bind do |input_data|
5960
Registry.resolve("sharepoint.commands.create_folder").call(storage:, auth_strategy:, input_data:)
6061
end
6162

@@ -67,17 +68,15 @@ module Commands
6768
end
6869

6970
it "when the folder is not found, returns a failure", vcr: "sharepoint/delete_folder_not_found" do
70-
result = Input::DeleteFolder.build(location: composite_identifier("NOT_HERE")).bind do |input_data|
71+
result = Input::DeleteFolder.build(
72+
location: SharepointSpecHelper.composite_identifier(base_drive, "NOT_HERE")
73+
).bind do |input_data|
7174
described_class.call(storage:, auth_strategy:, input_data:)
7275
end
7376

7477
expect(result).to be_failure
7578
expect(result.failure.code).to eq(:not_found)
7679
end
77-
78-
private
79-
80-
def composite_identifier(item_id) = "#{base_drive}#{SharepointStorage::IDENTIFIER_SEPARATOR}#{item_id}"
8180
end
8281
end
8382
end
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
require_module_spec_helper
33+
34+
module Storages
35+
module Adapters
36+
module Providers
37+
module Sharepoint
38+
module Commands
39+
RSpec.describe UploadFileCommand, :webmock do
40+
let(:storage) { create(:sharepoint_storage, :sandbox) }
41+
let(:auth_strategy) { Registry["sharepoint.authentication.userless"].call }
42+
let(:base_drive) { "b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY9jo6leJDqrT7muzvmiWjFW" }
43+
let(:input_data) { Input::UploadFile.build(parent_location:, file_name:, io:).value! }
44+
let(:parent_location) { SharepointSpecHelper.composite_identifier(base_drive, nil) }
45+
let(:file_name) { "test-file.txt" }
46+
let(:io) { StringIO.new("This is the blueprints of the first Death Star.") }
47+
48+
it_behaves_like "storage adapter: command call signature", "upload_file"
49+
50+
context "when uploading a file to the root folder", vcr: "sharepoint/upload_file_root" do
51+
it_behaves_like "adapter upload_file_command: successful file upload"
52+
end
53+
54+
context "when uploading a file to a sub-folder", vcr: "sharepoint/upload_file_subfolder" do
55+
let(:parent_location) do
56+
SharepointSpecHelper.composite_identifier(base_drive, "01ANJ53W5P3SUY3ZCDTRA3KLXRGA5A2M3S")
57+
end
58+
59+
it_behaves_like "adapter upload_file_command: successful file upload"
60+
end
61+
62+
context "when uploading a file to a non-existing folder", vcr: "sharepoint/upload_file_not_found" do
63+
let(:parent_location) do
64+
SharepointSpecHelper.composite_identifier(base_drive, "01AZJL5PKU2WV3U3RKKFF4A7ZCWVBXRTEU")
65+
end
66+
let(:error_source) { Adapters::Providers::Sharepoint::Queries::UploadLinkQuery }
67+
68+
it_behaves_like "storage adapter: error response", :not_found
69+
end
70+
71+
context "when uploading a file that has a filename with non-ASCII characters",
72+
vcr: "sharepoint/upload_file_unicode" do
73+
let(:file_name) { "🍑 is not spelled Pfürsich.txt" }
74+
75+
it_behaves_like "adapter upload_file_command: successful file upload"
76+
end
77+
78+
context "when upload session creation fails", vcr: "sharepoint/upload_file_session_failed" do
79+
let(:parent_location) { SharepointSpecHelper.composite_identifier(base_drive, "INVALID_ID") }
80+
let(:error_source) { Adapters::Providers::Sharepoint::Queries::UploadLinkQuery }
81+
82+
it_behaves_like "storage adapter: error response", :not_found
83+
end
84+
85+
context "when file upload fails", vcr: "sharepoint/upload_file_root" do
86+
let(:parent_location) { SharepointSpecHelper.composite_identifier(base_drive, nil) }
87+
let(:error_source) { described_class }
88+
89+
before do
90+
stub_request(:put, %r{https://.*\.sharepoint\.com/.*/uploadSession})
91+
.with(headers: { "Content-Range" => /bytes \d+-\d+\/\d+/ })
92+
.to_return(
93+
status: 403,
94+
body: { error: { code: "Forbidden", message: "Access denied" } }.to_json,
95+
headers: { "Content-Type" => "application/json" }
96+
)
97+
end
98+
99+
it_behaves_like "storage adapter: error response", :forbidden
100+
end
101+
end
102+
end
103+
end
104+
end
105+
end
106+
end

modules/storages/spec/spec_helper.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
config.filter_sensitive_data("<ACCESS_TOKEN>") do
4747
ENV.fetch("NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN", "MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN")
4848
end
49+
config.filter_sensitive_data("<SHAREPOINT_CLIENT_SECRET>") do
50+
ENV.fetch("SHAREPOINT_TEST_OAUTH_CLIENT_SECRET", "MISSING_SHARE_POINT_TEST_OAUTH_CLIENT_SECRET")
51+
end
52+
config.filter_sensitive_data("<SHAREPOINT_CLIENT_ID>") do
53+
ENV.fetch("SHAREPOINT_TEST_OAUTH_CLIENT_ID", "MISSING_SHARE_POINT_TEST_OAUTH_CLIENT_ID")
54+
end
4955
end
5056

5157
def use_storages_vcr_cassette(name, options = {}, &)

0 commit comments

Comments
 (0)