mirror of https://github.com/docusealco/docuseal
Add download URL generator endpoint for API (#37)
* Add download URL generator endpoint for API * rubocop/spec fixes * move logic out of view into controller * move build_signed_urls method logic into new SignedDocumentUrlBuilder service * slim down controller and its request specspull/608/head
parent
f2f2847908
commit
5abec47b94
@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SignedDocumentUrlsController < ApiBaseController
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def show
|
||||
last_submitter = @submission.last_completed_submitter
|
||||
|
||||
if last_submitter.blank?
|
||||
return render json: { error: 'Submission not completed' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# Ensure documents are generated
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
render json: {
|
||||
submission_id: @submission.id,
|
||||
submitter_id: last_submitter.id,
|
||||
documents: SignedDocumentUrlBuilder.new(last_submitter).call
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SignedDocumentUrlBuilder
|
||||
URL_EXPIRATION_TIME = 1.hour
|
||||
|
||||
def initialize(submitter)
|
||||
@submitter = submitter
|
||||
end
|
||||
|
||||
def call
|
||||
attachments.map do |attachment|
|
||||
{
|
||||
name: attachment.filename.to_s,
|
||||
url: generate_url(attachment),
|
||||
size_bytes: attachment.blob.byte_size,
|
||||
content_type: attachment.blob.content_type
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :submitter
|
||||
|
||||
def attachments
|
||||
Submitters.select_attachments_for_download(submitter)
|
||||
end
|
||||
|
||||
def generate_url(attachment)
|
||||
if uses_secured_storage?(attachment)
|
||||
DocumentSecurityService.signed_url_for(attachment)
|
||||
else
|
||||
ActiveStorage::Blob.proxy_url(
|
||||
attachment.blob,
|
||||
expires_at: URL_EXPIRATION_TIME.from_now.to_i
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def uses_secured_storage?(attachment)
|
||||
attachment.blob.service_name == 'aws_s3_secured'
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,155 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Signed Document URLs API' do
|
||||
let(:account) { create(:account, :with_testing_account) }
|
||||
let(:author) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author:) }
|
||||
|
||||
describe 'GET /api/submissions/:submission_id/signed_document_url' do
|
||||
context 'with a completed submission' do
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||
let(:completed_submitter) { submission.submitters.first }
|
||||
let(:builder) { instance_double(SignedDocumentUrlBuilder) }
|
||||
let(:fake_docs) { [{ name: 'completed-document.pdf', url: 'http://example.com/doc', size_bytes: 123, content_type: 'application/pdf' }] }
|
||||
|
||||
before do
|
||||
completed_submitter.update!(completed_at: Time.current)
|
||||
allow(SignedDocumentUrlBuilder).to receive(:new).with(completed_submitter).and_return(builder)
|
||||
allow(builder).to receive(:call).and_return(fake_docs)
|
||||
end
|
||||
|
||||
it 'returns signed URLs for completed documents' do
|
||||
allow(Submissions::EnsureResultGenerated).to receive(:call)
|
||||
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['submission_id']).to eq(submission.id)
|
||||
expect(response.parsed_body['submitter_id']).to eq(completed_submitter.id)
|
||||
expect(response.parsed_body['documents']).to be_an(Array)
|
||||
expect(response.parsed_body['documents'].first['name']).to eq('completed-document.pdf')
|
||||
end
|
||||
|
||||
it 'calls EnsureResultGenerated to generate documents' do
|
||||
allow(Submissions::EnsureResultGenerated).to receive(:call)
|
||||
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(SignedDocumentUrlBuilder).to have_received(:new).with(completed_submitter)
|
||||
expect(builder).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an incomplete submission' do
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||
|
||||
it 'returns an error when submission is not completed' do
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Submission not completed' })
|
||||
end
|
||||
|
||||
it 'does not call EnsureResultGenerated for incomplete submissions' do
|
||||
allow(Submissions::EnsureResultGenerated).to receive(:call)
|
||||
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(Submissions::EnsureResultGenerated).not_to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple completed submitters' do
|
||||
let(:template) { create(:template, submitter_count: 2, account:, author:) }
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||
let(:last_submitter) { submission.submitters.last }
|
||||
let(:builder) { instance_double(SignedDocumentUrlBuilder, call: [{ name: 'second-document.pdf', url: 'http://example.com/2', size_bytes: 1, content_type: 'application/pdf' }]) }
|
||||
|
||||
before do
|
||||
submission.submitters.first.update!(completed_at: 1.hour.ago)
|
||||
last_submitter.update!(completed_at: Time.current)
|
||||
allow(Submissions::EnsureResultGenerated).to receive(:call)
|
||||
allow(SignedDocumentUrlBuilder).to receive(:new).with(last_submitter).and_return(builder)
|
||||
end
|
||||
|
||||
it 'returns documents from the last completed submitter' do
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['submitter_id']).to eq(last_submitter.id)
|
||||
expect(response.parsed_body['documents'].first['name']).to eq('second-document.pdf')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with authorization' do
|
||||
let(:testing_account) { account.testing_accounts.first }
|
||||
let(:testing_author) { create(:user, account: testing_account) }
|
||||
let(:testing_template) { create(:template, account: testing_account, author: testing_author) }
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||
|
||||
before { submission.submitters.first.update!(completed_at: Time.current) }
|
||||
|
||||
it 'returns error when using testing API token for production submission' do
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': testing_author.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to include('testing API key')
|
||||
end
|
||||
|
||||
it 'returns error when using production API token for testing submission' do
|
||||
testing_submission = create(
|
||||
:submission,
|
||||
:with_submitters,
|
||||
template: testing_template,
|
||||
created_by_user: testing_author
|
||||
)
|
||||
testing_submission.submitters.first.update!(completed_at: Time.current)
|
||||
|
||||
get "/api/submissions/#{testing_submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to include('production API key')
|
||||
end
|
||||
|
||||
it 'returns error when no auth token is provided' do
|
||||
get "/api/submissions/#{submission.id}/signed_document_url"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' })
|
||||
end
|
||||
|
||||
it 'raises RecordNotFound when submission does not exist' do
|
||||
expect do
|
||||
get '/api/submissions/99999/signed_document_url',
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when EnsureResultGenerated fails' do
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: author) }
|
||||
|
||||
before do
|
||||
submission.submitters.first.update!(completed_at: Time.current)
|
||||
end
|
||||
|
||||
it 'propagates the error' do
|
||||
allow(Submissions::EnsureResultGenerated).to receive(:call).and_raise(StandardError, 'Generation failed')
|
||||
|
||||
expect do
|
||||
get "/api/submissions/#{submission.id}/signed_document_url",
|
||||
headers: { 'x-auth-token': author.access_token.token }
|
||||
end.to raise_error(StandardError, 'Generation failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,115 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SignedDocumentUrlBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:author) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author:) }
|
||||
let(:submission) { create(:submission, :with_submitters, template:, account:, created_by_user: author) }
|
||||
let(:submitter) { submission.submitters.first }
|
||||
let(:blob) do
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new('test pdf content'),
|
||||
filename: 'test-document.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
ActiveStorage::Current.url_options = { host: 'test.example.com' }
|
||||
submitter.update!(completed_at: Time.current)
|
||||
submitter.documents.attach(blob)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
subject(:builder) { described_class.new(submitter) }
|
||||
|
||||
it 'returns an array of document hashes' do
|
||||
result = builder.call
|
||||
|
||||
expect(result).to be_an(Array)
|
||||
expect(result.size).to eq(1)
|
||||
end
|
||||
|
||||
it 'includes document metadata' do
|
||||
result = builder.call.first
|
||||
|
||||
expect(result[:name]).to eq('test-document.pdf')
|
||||
expect(result[:url]).to be_present
|
||||
expect(result[:size_bytes]).to be_a(Integer)
|
||||
expect(result[:content_type]).to eq('application/pdf')
|
||||
end
|
||||
|
||||
context 'with standard storage' do
|
||||
it 'generates ActiveStorage proxy URLs' do
|
||||
result = builder.call.first
|
||||
|
||||
expect(result[:url]).to include('/file/')
|
||||
end
|
||||
|
||||
it 'includes expiration time in the URL' do
|
||||
expected_expiration = 1.hour.from_now.to_i
|
||||
|
||||
allow(ActiveStorage::Blob).to receive(:proxy_url).and_call_original
|
||||
builder.call
|
||||
|
||||
expect(ActiveStorage::Blob).to have_received(:proxy_url)
|
||||
.with(blob, hash_including(expires_at: expected_expiration))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with secured storage (aws_s3_secured)' do
|
||||
subject(:builder) { described_class.new(submitter_two) }
|
||||
|
||||
let(:submitter_two) { submission.submitters.second || create(:submitter, submission:, uuid: 'submitter-uuid-1') }
|
||||
let(:secured_blob) do
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new('test pdf content'),
|
||||
filename: 'secured-document.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
submitter_two.update!(completed_at: Time.current)
|
||||
submitter_two.documents.attach(secured_blob)
|
||||
|
||||
# Stub the service_name method to simulate secured storage
|
||||
# rubocop:disable RSpec/AnyInstance
|
||||
allow_any_instance_of(ActiveStorage::Blob).to receive(:service_name).and_return('aws_s3_secured')
|
||||
# rubocop:enable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
it 'uses DocumentSecurityService for signed URLs' do
|
||||
allow(DocumentSecurityService).to receive(:signed_url_for)
|
||||
.and_return('https://signed-url.example.com')
|
||||
|
||||
result = builder.call.first
|
||||
|
||||
expect(result[:url]).to eq('https://signed-url.example.com')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple documents' do
|
||||
let(:blob2) do
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new('second pdf content'),
|
||||
filename: 'second-document.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
submitter.documents.attach(blob2)
|
||||
end
|
||||
|
||||
it 'returns all documents' do
|
||||
result = builder.call
|
||||
|
||||
expect(result.size).to eq(2)
|
||||
expect(result.pluck(:name)).to contain_exactly('test-document.pdf', 'second-document.pdf')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue