Skip to content

Commit cfe25e5

Browse files
committed
Add ActiveStorage Uploadcare previewer and remote variant processing
Integrate Uploadcare-backed ActiveStorage behavior into the gem so apps can use standard Rails APIs (preview and variant) without custom helpers. - auto-register Uploadcare PDF previewer in engine initialization - prepend remote variant processing for Uploadcare service blobs - map Rails-style resize_to_limit/resize_to_fill to Uploadcare transformations - add integration, previewer, and variant processing specs - document ActiveStorage integration usage in README
1 parent 3d32518 commit cfe25e5

File tree

8 files changed

+376
-0
lines changed

8 files changed

+376
-0
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,23 @@ Current limitations:
522522
* direct uploads are not supported yet (`url_for_direct_upload` raises `NotImplementedError`)
523523
* adapter stores Uploadcare UUID mapping in `ActiveStorage::Blob#metadata["uploadcare_uuid"]`
524524

525+
Built-in Active Storage integrations:
526+
527+
* Uploadcare PDF previewer (`Uploadcare::Rails::ActiveStorage::UploadcarePreviewer`) is auto-registered.
528+
This allows standard Rails preview calls like:
529+
530+
```ruby
531+
url_for(record.file.preview(resize_to_limit: [320, 320]))
532+
```
533+
534+
* Uploadcare remote variant processing is prepended into `ActiveStorage::Variant`.
535+
Standard Rails variant helpers keep working while transformations are executed through Uploadcare CDN:
536+
537+
```ruby
538+
image_tag(record.image.variant(resize_to_limit: [320, 320], quality: "smart"))
539+
image_tag(record.image.variant(resize_to_fill: [200, 120]))
540+
```
541+
525542
### Uploadcare API interfaces
526543

527544
Uploadcare provides [APIs](https://uploadcare.com/docs/start/api/) to manage files, group, projects, webhooks, video and documents conversion and file uploads. The gem has unified interfaces to use Uploadcare APIs in RailsApp.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require 'uploadcare/rails/active_storage/uploadcare_previewer'
4+
require 'uploadcare/rails/active_storage/variant_remote_processing'
5+
6+
module Uploadcare
7+
module Rails
8+
module ActiveStorage
9+
module Integration
10+
module_function
11+
12+
def install!(previewers:)
13+
install_previewer(previewers)
14+
install_variant_remote_processing
15+
end
16+
17+
def install_previewer(previewers)
18+
return if previewers.nil?
19+
return if previewers.include?(Uploadcare::Rails::ActiveStorage::UploadcarePreviewer)
20+
21+
previewers.unshift(Uploadcare::Rails::ActiveStorage::UploadcarePreviewer)
22+
end
23+
24+
def install_variant_remote_processing
25+
return unless defined?(::ActiveStorage::Variant)
26+
return if ::ActiveStorage::Variant < Uploadcare::Rails::ActiveStorage::VariantRemoteProcessing
27+
28+
::ActiveStorage::Variant.prepend(Uploadcare::Rails::ActiveStorage::VariantRemoteProcessing)
29+
end
30+
end
31+
end
32+
end
33+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
require 'active_storage/previewer'
4+
require 'marcel'
5+
require 'net/http'
6+
require 'tempfile'
7+
8+
module Uploadcare
9+
module Rails
10+
module ActiveStorage
11+
class UploadcarePreviewer < ::ActiveStorage::Previewer
12+
class << self
13+
def accept?(blob)
14+
!!(uploadcare_blob?(blob) && pdf?(blob.content_type))
15+
end
16+
17+
def uploadcare_blob?(blob)
18+
blob.service.is_a?(::ActiveStorage::Service::UploadcareService)
19+
rescue NameError
20+
false
21+
end
22+
23+
def pdf?(content_type)
24+
Marcel::Magic.child?(content_type, 'application/pdf')
25+
end
26+
end
27+
28+
def preview(**options)
29+
open_preview_io(preview_url) do |output|
30+
yield io: output, filename: "#{blob.filename.base}.png", content_type: 'image/png', **options
31+
end
32+
end
33+
34+
private
35+
36+
def preview_url
37+
file = Uploadcare::FileApi.get_file(uploadcare_uuid)
38+
"#{file.cdn_url}-/document/-/format/png/-/page/1/"
39+
end
40+
41+
def uploadcare_uuid
42+
blob.metadata['uploadcare_uuid'].presence || blob.key
43+
end
44+
45+
def open_preview_io(url)
46+
tempfile = Tempfile.open(['uploadcare-preview', '.png'], tmpdir)
47+
tempfile.binmode
48+
49+
response = http_get(url)
50+
raise ::ActiveStorage::PreviewError, "Uploadcare preview fetch failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
51+
52+
tempfile.write(response.body)
53+
tempfile.rewind
54+
yield tempfile
55+
ensure
56+
tempfile.close! if tempfile
57+
end
58+
59+
def http_get(url, limit = 5)
60+
raise ::ActiveStorage::PreviewError, 'Uploadcare preview redirect limit exceeded' if limit.zero?
61+
62+
uri = URI.parse(url)
63+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
64+
http.request(Net::HTTP::Get.new(uri))
65+
end
66+
67+
return http_get(response['location'], limit - 1) if response.is_a?(Net::HTTPRedirection)
68+
69+
response
70+
end
71+
end
72+
end
73+
end
74+
end
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# frozen_string_literal: true
2+
3+
require 'net/http'
4+
require 'tempfile'
5+
6+
module Uploadcare
7+
module Rails
8+
module ActiveStorage
9+
module VariantRemoteProcessing
10+
private
11+
12+
def process
13+
return super unless uploadcare_service?(service)
14+
15+
download_transformed_uploadcare_image do |output|
16+
service.upload(key, output, content_type: content_type)
17+
end
18+
end
19+
20+
def uploadcare_service?(service_object)
21+
service_object.is_a?(::ActiveStorage::Service::UploadcareService)
22+
rescue NameError
23+
false
24+
end
25+
26+
def download_transformed_uploadcare_image
27+
tempfile = Tempfile.open(['uploadcare-variant', ".#{variation.format}"], Dir.tmpdir)
28+
tempfile.binmode
29+
30+
response = http_get(variant_source_url)
31+
raise ::ActiveStorage::IntegrityError, "Uploadcare variant fetch failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
32+
33+
tempfile.write(response.body)
34+
tempfile.rewind
35+
yield tempfile
36+
ensure
37+
tempfile.close! if tempfile
38+
end
39+
40+
def variant_source_url
41+
file = Uploadcare::Rails::File.new({ uuid: uploadcare_uuid })
42+
file.transform_url(uploadcare_transformations)
43+
end
44+
45+
def uploadcare_uuid
46+
blob.metadata['uploadcare_uuid'].presence || blob.key
47+
end
48+
49+
def uploadcare_transformations
50+
mapped = variation.transformations.deep_symbolize_keys.except(:format)
51+
resize_to_limit = mapped.delete(:resize_to_limit)
52+
resize_to_fill = mapped.delete(:resize_to_fill)
53+
54+
if resize_to_limit.present?
55+
width, height = resize_to_limit
56+
mapped[:resize] = [width, height].compact.join('x')
57+
end
58+
59+
if resize_to_fill.present?
60+
width, height = resize_to_fill
61+
mapped[:scale_crop] = {
62+
dimensions: [width, height].compact.join('x'),
63+
offsets: '50%,50%'
64+
}
65+
end
66+
67+
mapped
68+
end
69+
70+
def http_get(url, limit = 5)
71+
raise ::ActiveStorage::IntegrityError, 'Uploadcare variant redirect limit exceeded' if limit.zero?
72+
73+
uri = URI.parse(url)
74+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
75+
http.request(Net::HTTP::Get.new(uri))
76+
end
77+
78+
return http_get(response['location'], limit - 1) if response.is_a?(Net::HTTPRedirection)
79+
80+
response
81+
end
82+
end
83+
end
84+
end
85+
end

lib/uploadcare/rails/engine.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ class Engine < ::Rails::Engine
2828
require 'uploadcare/rails/mongoid/mount_uploadcare_file_group'
2929
end
3030
end
31+
32+
initializer 'uploadcare-rails.active_storage' do
33+
require 'uploadcare/rails/active_storage/integration'
34+
35+
config.after_initialize do |app|
36+
previewers = app.config.active_storage.respond_to?(:previewers) ? app.config.active_storage.previewers : nil
37+
Uploadcare::Rails::ActiveStorage::Integration.install!(previewers: previewers)
38+
end
39+
end
3140
end
3241
end
3342
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'uploadcare/rails/active_storage/integration'
5+
6+
RSpec.describe Uploadcare::Rails::ActiveStorage::Integration do
7+
describe '.install_previewer' do
8+
it 'adds uploadcare previewer once' do
9+
previewers = []
10+
11+
described_class.install_previewer(previewers)
12+
described_class.install_previewer(previewers)
13+
14+
expect(previewers.count(Uploadcare::Rails::ActiveStorage::UploadcarePreviewer)).to eq(1)
15+
end
16+
17+
it 'does not fail when previewers is nil' do
18+
expect { described_class.install_previewer(nil) }.not_to raise_error
19+
end
20+
end
21+
22+
describe '.install_variant_remote_processing' do
23+
it 'prepends remote processing module once' do
24+
stub_const('ActiveStorage::Variant', Class.new)
25+
26+
described_class.install_variant_remote_processing
27+
described_class.install_variant_remote_processing
28+
29+
expect(ActiveStorage::Variant.ancestors.count(Uploadcare::Rails::ActiveStorage::VariantRemoteProcessing)).to eq(1)
30+
end
31+
end
32+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'active_storage/errors'
5+
require 'active_storage/service/uploadcare_service'
6+
require 'uploadcare/rails/active_storage/uploadcare_previewer'
7+
8+
RSpec.describe Uploadcare::Rails::ActiveStorage::UploadcarePreviewer do
9+
let(:service) { ActiveStorage::Service::UploadcareService.new(public_key: 'demopublickey', secret_key: 'demosecretkey') }
10+
let(:uuid) { '2d33999d-c74a-4ff9-99ea-abc23496b052' }
11+
let(:filename) { double(base: 'report') }
12+
let(:blob) do
13+
double(
14+
service: service,
15+
content_type: 'application/pdf',
16+
metadata: { 'uploadcare_uuid' => uuid },
17+
key: 'fallback-key',
18+
filename: filename
19+
)
20+
end
21+
22+
describe '.accept?' do
23+
it 'accepts uploadcare-backed pdf blobs' do
24+
expect(described_class.accept?(blob)).to eq(true)
25+
end
26+
27+
it 'rejects non-pdf blobs' do
28+
image_blob = double(service: service, content_type: 'image/png')
29+
expect(described_class.accept?(image_blob)).to eq(false)
30+
end
31+
end
32+
33+
describe '#preview' do
34+
it 'yields png preview attachable payload' do
35+
previewer = described_class.new(blob)
36+
allow(Uploadcare::FileApi).to receive(:get_file).with(uuid).and_return(double(cdn_url: "https://ucarecdn.com/#{uuid}/"))
37+
38+
response = Net::HTTPOK.new('1.1', '200', 'OK')
39+
allow(response).to receive(:body).and_return('png-preview-data')
40+
allow(previewer).to receive(:http_get).and_return(response)
41+
42+
yielded = nil
43+
previewer.preview do |attachable|
44+
yielded = attachable
45+
expect(attachable[:io].read).to eq('png-preview-data')
46+
end
47+
48+
expect(yielded[:filename].to_s).to eq('report.png')
49+
expect(yielded[:content_type]).to eq('image/png')
50+
end
51+
end
52+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'active_storage/errors'
5+
require 'active_storage/service/uploadcare_service'
6+
require 'uploadcare/rails/active_storage/variant_remote_processing'
7+
8+
RSpec.describe Uploadcare::Rails::ActiveStorage::VariantRemoteProcessing do
9+
let(:service) { ActiveStorage::Service::UploadcareService.new(public_key: 'demopublickey', secret_key: 'demosecretkey') }
10+
let(:uuid) { '2d33999d-c74a-4ff9-99ea-abc23496b052' }
11+
12+
let(:variant_host_class) do
13+
Class.new do
14+
prepend Uploadcare::Rails::ActiveStorage::VariantRemoteProcessing
15+
16+
attr_reader :service, :blob, :variation
17+
18+
def initialize(service:, blob:, variation:)
19+
@service = service
20+
@blob = blob
21+
@variation = variation
22+
end
23+
24+
def key
25+
'variant-key'
26+
end
27+
28+
def content_type
29+
'image/png'
30+
end
31+
32+
def process
33+
:base_process_called
34+
end
35+
end
36+
end
37+
38+
let(:blob) { double(metadata: { 'uploadcare_uuid' => uuid }, key: 'blob-key') }
39+
let(:variation) { double(format: 'png', transformations: { resize_to_limit: [320, 320], quality: 'smart' }) }
40+
41+
it 'downloads transformed image from uploadcare and uploads variant to service' do
42+
host = variant_host_class.new(service: service, blob: blob, variation: variation)
43+
44+
allow(service).to receive(:upload)
45+
46+
file = instance_double(Uploadcare::Rails::File)
47+
allow(Uploadcare::Rails::File).to receive(:new).with({ uuid: uuid }).and_return(file)
48+
allow(file).to receive(:transform_url).with(hash_including(resize: '320x320', quality: 'smart')).and_return("https://ucarecdn.com/#{uuid}/-/resize/320x320/-/quality/smart/")
49+
50+
response = Net::HTTPOK.new('1.1', '200', 'OK')
51+
allow(response).to receive(:body).and_return('transformed-bytes')
52+
allow(host).to receive(:http_get).and_return(response)
53+
54+
host.send(:process)
55+
56+
expect(service).to have_received(:upload).with('variant-key', anything, content_type: 'image/png')
57+
end
58+
59+
it 'maps resize_to_fill into uploadcare scale_crop operation' do
60+
fill_variation = double(format: 'png', transformations: { resize_to_fill: [200, 100] })
61+
host = variant_host_class.new(service: service, blob: blob, variation: fill_variation)
62+
63+
mapped = host.send(:uploadcare_transformations)
64+
65+
expect(mapped[:scale_crop]).to eq({ dimensions: '200x100', offsets: '50%,50%' })
66+
end
67+
68+
it 'falls back to base process when service is not uploadcare service' do
69+
non_uploadcare_service = Object.new
70+
host = variant_host_class.new(service: non_uploadcare_service, blob: blob, variation: variation)
71+
72+
expect(host.send(:process)).to eq(:base_process_called)
73+
end
74+
end

0 commit comments

Comments
 (0)