Skip to content

[OP#64194] SharePoint UploadLinkQuery #19886

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class << self
# @return [AuthenticationStrategy]
# rubocop:disable Metrics/AbcSize
def [](strategy)
auth = strategy.value_or { |it| raise ArgumentError, "Invalid authentication strategy '#{it.inspect}'" }
auth = strategy.value_or { raise ArgumentError, "Invalid authentication strategy '#{it.inspect}'" }

case auth.key
when :noop
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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
module Adapters
module Providers
module SharePoint
module Queries
class UploadLinkQuery < Base
def call(auth_strategy:, input_data:)
with_tagged_logger do
Authentication[auth_strategy].call(storage: @storage) do |http|
info "Requesting an upload link on folder #{input_data.folder_id}"
handle_response(
http.post(request_uri(input_data.folder_id, input_data.file_name), json: payload(input_data.file_name))
)
end
end
end

private

def payload(filename)
{ item: { "@microsoft.graph.conflictBehavior" => "rename", name: filename } }
end

# rubocop:disable Metrics/AbcSize
def handle_response(response)
error = Results::Error.new(source: self.class, payload: response)

case response
in { status: 200..299 }
upload_url = response.json(symbolize_keys: true)[:uploadUrl]
info "Upload link generated successfully."
Results::UploadLink.build(destination: upload_url, method: :put)
in { status: 404 | 400 } # not existent parent folder in request url is responded with 400
info "The parent folder was not found."
Failure(error.with(code: :not_found))
in { status: 401 }
info "User authorization failed."
Failure(error.with(code: :unauthorized))
in { status: 403 }
info "User authorization failed."
Failure(error.with(code: :forbidden))
else
info "Unknown error happened."
Failure(error.with(code: :error))
end
end
# rubocop:enable Metrics/AbcSize

def request_uri(folder, filename)
split_identifier(folder) => { drive_id:, location: }
base = location.root? ? root_url(drive_id) : item_url(drive_id, location)
file_path = UrlBuilder.path(filename)

"#{base}:#{file_path}:/createUploadSession"
end

def root_url(drive_id)
UrlBuilder.url(base_uri, "/v1.0/drives", drive_id, "/items/root")
end

def item_url(drive_id, location)
UrlBuilder.url(base_uri, "/v1.0/drives", drive_id, "/items", location)
end
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ module SharePoint

namespace("queries") do
register(:files, Queries::FilesQuery)
register(:upload_link, Queries::UploadLinkQuery)
register(:user, OneDrive::Queries::UserQuery)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,7 @@ module Results
private_class_method :new

def self.build(destination:, method:, contract: UploadLinkContract.new)
contract.call(destination:, method:).to_monad.fmap do |it|
params = it.to_h
new(URI(params[:destination]), params[:method])
end
contract.call(destination: URI(destination), method:).to_monad.fmap { new(**it.to_h) }
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -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

module Storages
module Adapters
module Providers
module SharePoint
module Queries
RSpec.describe UploadLinkQuery, :webmock do
let(:storage) { create(:share_point_storage, :sandbox) }
let(:auth_strategy) { Registry["share_point.authentication.userless"].call }
let(:upload_method) { :put }
let(:input_data) { Input::UploadLink.build(folder_id:, file_name:).value! }
let(:file_name) { "DeathStart_blueprints.tiff" }

it_behaves_like "adapter upload_link_query: basic query setup"

context "when creating an upload link to the root folder of a list", vcr: "share_point/upload_link_success" do
let(:folder_id) { "b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY8Qconfm2i6SKEoCmuGYqQK" }

let(:token) do
"v1.eyJzaXRlaWQiOiIxMDk5ZTMxNS1kMGM3LTQ3YzctODY0MC1hYTk1MDRiNzBmZmYiLCJhcHBfZGlzcGxheW5hbWUiOiJPUCBTZ" \
"WxlY3RlZCIsIm5hbWVpZCI6IjQwNGUwNTY3LTE5YTEtNGE2NC1iMmQxLWQzYjBjOTlmMmVkOUBlMzZmMWRiYy1mZGFlLTQyN2UtYj" \
"YxYi0wZDk2ZGRmYjgxYTQiLCJhdWQiOiIwMDAwMDAwMy0wMDAwLTBmZjEtY2UwMC0wMDAwMDAwMDAwMDAveW10NmQuc2hhcmVwb2l" \
"udC5jb21AZTM2ZjFkYmMtZmRhZS00MjdlLWI2MWItMGQ5NmRkZmI4MWE0IiwiZXhwIjoiMTc1NDk5NTA4NSJ9.CkAKDGVudHJhX2N" \
"sYWltcxIwQ09DTjU4UUdFQUFhRmt4TWEwZHlNV3R5ZDJ0MVN6WmFiemxwYkhCbVFVRXFBQT09CjIKCmFjdG9yYXBwaWQSJDAwMDAw" \
"MDAzLTAwMDAtMDAwMC1jMDAwLTAwMDAwMDAwMDAwMAoKCgRzbmlkEgI2NhILCKa2j4Kms6w-EAUaDjIwLjE5MC4xOTAuMTAwKixhM" \
"1l1blg1YkN6MFBCVjM3S2RGblA1QWdyL08wYllnS0FIeU1rU3Rvam1jPTCBAjgBQhChuq4krFAAAHqMwh13MKvaShBoYXNoZWRwcm" \
"9vZnRva2VuegExugENc2VsZWN0ZWRzaXRlc8gBAQ._udXSV3Fv81Ws1n5RdRe3aKpVRVZIewLPNiHh8CVjy0"
end

let(:upload_url) do
"https://ymt6d.sharepoint.com/sites/OPTest/_api/v2.0/drives/" \
"b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY8Qconfm2i6SKEoCmuGYqQK/items/01ANJ53WYPWYGIOMO3WFGK2YENXB7ESIS5" \
"/uploadSession?guid=%275fa68ee0-d944-4323-817a-2bf0ece08ae5%27&overwrite=False&rename=True&dc=0&tempa" \
"uth=#{token}"
end

it_behaves_like "adapter upload_link_query: successful upload link response"
end

context "when creating an upload link to a subfolder on a list", vcr: "share_point/upload_link_subfolder" do
let(:folder_id) do
"b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY9jo6leJDqrT7muzvmiWjFW||01ANJ53W5P3SUY3ZCDTRA3KLXRGA5A2M3S"
end
let(:token) do
"v1.eyJzaXRlaWQiOiIxMDk5ZTMxNS1kMGM3LTQ3YzctODY0MC1hYTk1MDRiNzBmZmYiLCJhcHBfZGlzcGxheW5hbWUiOiJPUCBTZWx" \
"lY3RlZCIsIm5hbWVpZCI6IjQwNGUwNTY3LTE5YTEtNGE2NC1iMmQxLWQzYjBjOTlmMmVkOUBlMzZmMWRiYy1mZGFlLTQyN2UtYjYx" \
"Yi0wZDk2ZGRmYjgxYTQiLCJhdWQiOiIwMDAwMDAwMy0wMDAwLTBmZjEtY2UwMC0wMDAwMDAwMDAwMDAveW10NmQuc2hhcmVwb2lud" \
"C5jb21AZTM2ZjFkYmMtZmRhZS00MjdlLWI2MWItMGQ5NmRkZmI4MWE0IiwiZXhwIjoiMTc1NTA5MDQ5OSJ9.CkAKDGVudHJhX2NsY" \
"WltcxIwQ0piMzdNUUdFQUFhRmpKMU16bElUalJHTFZVeVJWQm9OblpPTmpSUlFVRXFBQT09CjIKCmFjdG9yYXBwaWQSJDAwMDAwMD" \
"AzLTAwMDAtMDAwMC1jMDAwLTAwMDAwMDAwMDAwMAoKCgRzbmlkEgI2NhILCI7l0_Dq6qw-EAUaDjIwLjE5MC4xOTAuMTAyKixZYld" \
"pRDFScDM5SGNsREo0dXQrck5xUjJjelRwbmgya3lSZlhWVnVaZnVBPTCBAjgBQhChuwki92AAAHU0wDr4k3yFShBoYXNoZWRwcm9v" \
"ZnRva2VuegExugENc2VsZWN0ZWRzaXRlc8gBAQ.oFWNiNRCsbAVkadc7B2tMRqH4uqjR0oINcqsdEP8DOg"
end

let(:upload_url) do
"https://ymt6d.sharepoint.com/sites/OPTest/_api/v2.0/drives/b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY" \
"9jo6leJDqrT7muzvmiWjFW/items/01ANJ53W2OGTVJLOZ5R5F2NFJWGJOLV56J/uploadSession?guid=%27ca9855a1-245f-40b" \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 wanna use drive_id as a variable and interpolate it here? Same in the folder_id?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might have some value to interpolate the drive id, but folder ID becomes moot point as the API generates the new ID of the upload and returns it as part of the URL, ignoring the parent folder.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 just a suggestion: maybe to put the token to a file or factory 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shiroginne the token is generated per request. =/

"6-a2c3-ce8053d3a381%27&overwrite=False&rename=True&dc=0&tempauth=#{token}"
end

it_behaves_like "adapter upload_link_query: successful upload link response"
end

context "when requesting an upload link for a not existing file", vcr: "share_point/upload_link_not_found" do
let(:input_data) do
Input::UploadLink.build(
folder_id:
"b!FeOZEMfQx0eGQKqVBLcP__BG8mq-4-9FuRqOyk3MXY8Qconfm2i6SKEoCmuGYqQK||04AZJL5PN6Y2GOVW7725BZO354PWSELRRZ",
file_name: "DeathStart_blueprints.tiff"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣 so, a Death Start, yes?

Copy link
Contributor Author

@mereghost mereghost Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep it is a blueprint after all. And a TIFF file nonetheless. XD

).value!
end

it_behaves_like "adapter upload_link_query: not found"
end
end
end
end
end
end
end

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading