diff --git a/CHANGELOG.md b/CHANGELOG.md index ec420807..fc400ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based now on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +# Unreleased + +* Adds mongoid support for `mount_uploadcare_file` and `mount_uploadcare_file_group` methods. + ## 3.4.3 — 2024-06-01 ### Added diff --git a/Gemfile b/Gemfile index a71328a1..47363d36 100644 --- a/Gemfile +++ b/Gemfile @@ -7,8 +7,12 @@ gemspec gem 'http-parser', '~> 1.2', '>= 1.2.3' gem 'rake', '~> 13.0.6' -gem 'rspec', '~> 3.12' -gem 'rspec-rails', '>= 5.1' -gem 'rubocop', '~> 1.48' -gem 'vcr', '~> 6.1' -gem 'webmock', '~> 3.18' + +group :test do + gem 'mongoid', '~> 9', require: false + gem 'rspec', '~> 3.12' + gem 'rspec-rails', '>= 5.1' + gem 'rubocop', '~> 1.48' + gem 'vcr', '~> 6.1' + gem 'webmock', '~> 3.18' +end diff --git a/README.md b/README.md index a5ffb6ac..d8f13e51 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,8 @@ document.addEventListener('turbo:before-cache', function() { When you mount either Uploadcare File or Group to an attribute, this attribute is getting wrapped with a Uploadcare object. This feature adds some useful methods to the attribute. +Note: Supports ActiveRecord, ActiveModel and Mongoid models. + #### Uploadcare File Say, you have such model in your Rails app: diff --git a/gemfiles/Gemfile-rails-6-1 b/gemfiles/Gemfile-rails-6-1 index e1b7e46c..5b9ac239 100644 --- a/gemfiles/Gemfile-rails-6-1 +++ b/gemfiles/Gemfile-rails-6-1 @@ -11,3 +11,4 @@ gem 'rspec-rails', '>= 5.1' gem 'rubocop', '~> 1.48' gem 'vcr', '~> 6.1' gem 'webmock', '~> 3.18' +gem 'mongoid', '~> 9', require: false diff --git a/gemfiles/Gemfile-rails-7-0 b/gemfiles/Gemfile-rails-7-0 index a92d6574..262bc6df 100644 --- a/gemfiles/Gemfile-rails-7-0 +++ b/gemfiles/Gemfile-rails-7-0 @@ -11,3 +11,4 @@ gem 'rspec-rails', '>= 5.1' gem 'rubocop', '~> 1.48' gem 'vcr', '~> 6.1' gem 'webmock', '~> 3.18' +gem 'mongoid', '~> 9', require: false diff --git a/gemfiles/Gemfile-rails-7-1 b/gemfiles/Gemfile-rails-7-1 index 3eca931f..6a66b578 100644 --- a/gemfiles/Gemfile-rails-7-1 +++ b/gemfiles/Gemfile-rails-7-1 @@ -11,3 +11,4 @@ gem 'rspec-rails', '>= 5.1' gem 'rubocop', '~> 1.48' gem 'vcr', '~> 6.1' gem 'webmock', '~> 3.18' +gem 'mongoid', '~> 9', require: false diff --git a/lib/uploadcare/rails/engine.rb b/lib/uploadcare/rails/engine.rb index d4942ec5..4705d925 100644 --- a/lib/uploadcare/rails/engine.rb +++ b/lib/uploadcare/rails/engine.rb @@ -20,6 +20,13 @@ class Engine < ::Rails::Engine require 'uploadcare/rails/active_record/mount_uploadcare_file' require 'uploadcare/rails/active_record/mount_uploadcare_file_group' end + + # Load extensions for mongoid + # Extend mongoid with mount_uploadcare_file and mount_uploadcare_file_group methods + ActiveSupport.on_load :mongoid do + require 'uploadcare/rails/mongoid/mount_uploadcare_file' + require 'uploadcare/rails/mongoid/mount_uploadcare_file_group' + end end end end diff --git a/lib/uploadcare/rails/mongoid/mount_uploadcare_file.rb b/lib/uploadcare/rails/mongoid/mount_uploadcare_file.rb new file mode 100644 index 00000000..55db2560 --- /dev/null +++ b/lib/uploadcare/rails/mongoid/mount_uploadcare_file.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'mongoid' +require 'active_support/concern' +require 'uploadcare/rails/services/id_extractor' +require 'uploadcare/rails/jobs/delete_file_job' +require 'uploadcare/rails/jobs/store_file_job' +require 'uploadcare/rails/objects/file' + +module Uploadcare + module Rails + module Mongoid + # A module containing Mongoid extension. Allows using uploadcare file methods in Mongoid models + module MountUploadcareFile + extend ActiveSupport::Concern + + def build_uploadcare_file(attribute) + cdn_url = read_attribute(attribute).to_s + return if cdn_url.empty? + + uuid = IdExtractor.call(cdn_url) + cache_key = File.build_cache_key(cdn_url) + default_attributes = { cdn_url: cdn_url, uuid: uuid.presence } + file_attributes = ::Rails.cache.read(cache_key).presence || default_attributes + Uploadcare::Rails::File.new(file_attributes) + end + + class_methods do + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def mount_uploadcare_file(attribute) + define_method attribute do + build_uploadcare_file(attribute) + end + + define_method "uploadcare_store_#{attribute}!" do |store_job = StoreFileJob| + file_uuid = public_send(attribute)&.uuid + return unless file_uuid + return store_job.perform_later(file_uuid) if Uploadcare::Rails.configuration.store_files_async + + Uploadcare::FileApi.store_file(file_uuid) + end + + define_method "uploadcare_delete_#{attribute}!" do |delete_job = DeleteFileJob| + file_uuid = public_send(attribute)&.uuid + return unless file_uuid + return delete_job.perform_later(file_uuid) if Uploadcare::Rails.configuration.delete_files_async + + Uploadcare::FileApi.delete_file(file_uuid) + end + + unless Uploadcare::Rails.configuration.do_not_store + set_callback(:save, :after, :"uploadcare_store_#{attribute}!", if: :"#{attribute}_changed?") + end + + return unless Uploadcare::Rails.configuration.delete_files_after_destroy + + set_callback(:destroy, :after, :"uploadcare_delete_#{attribute}!") + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + end + end + end + end +end + +Mongoid::Document.include Uploadcare::Rails::Mongoid::MountUploadcareFile diff --git a/lib/uploadcare/rails/mongoid/mount_uploadcare_file_group.rb b/lib/uploadcare/rails/mongoid/mount_uploadcare_file_group.rb new file mode 100644 index 00000000..65cc33fb --- /dev/null +++ b/lib/uploadcare/rails/mongoid/mount_uploadcare_file_group.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'mongoid' +require 'active_support/concern' +require 'uploadcare/rails/services/id_extractor' +require 'uploadcare/rails/services/files_count_extractor' +require 'uploadcare/rails/jobs/store_group_job' +require 'uploadcare/rails/objects/group' + +module Uploadcare + module Rails + module Mongoid + # A module containing Mongoid extension. Allows to use uploadcare group methods in Rails models + module MountUploadcareFileGroup + extend ActiveSupport::Concern + + GROUP_ID_REGEX = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b~\d+/.freeze + + def build_uploadcare_file_group(attribute) + cdn_url = read_attribute(attribute).to_s + return if cdn_url.empty? + + group_id = IdExtractor.call(cdn_url, GROUP_ID_REGEX).presence + cache_key = Group.build_cache_key(cdn_url) + files_count = FilesCountExtractor.call(group_id) + default_attributes = { cdn_url: cdn_url, id: group_id, files_count: files_count } + file_attributes = ::Rails.cache.read(cache_key).presence || default_attributes + Uploadcare::Rails::Group.new(file_attributes) + end + + class_methods do + # rubocop:disable Metrics/MethodLength + def mount_uploadcare_file_group(attribute) + define_singleton_method "has_uploadcare_file_group_for_#{attribute}?" do + true + end + + define_method attribute do + build_uploadcare_file_group attribute + end + + define_method "uploadcare_store_#{attribute}!" do |store_job = StoreGroupJob| + group_id = public_send(attribute)&.id + return unless group_id + return store_job.perform_later(group_id) if Uploadcare::Rails.configuration.store_files_async + + Uploadcare::GroupApi.store_group(group_id) + end + + return if Uploadcare::Rails.configuration.do_not_store + + set_callback :save, :after, :"uploadcare_store_#{attribute}!" + end + # rubocop:enable Metrics/MethodLength + end + end + end + end +end + +Mongoid::Document.include Uploadcare::Rails::Mongoid::MountUploadcareFileGroup diff --git a/lib/uploadcare/rails/objects/concerns/loadable.rb b/lib/uploadcare/rails/objects/concerns/loadable.rb index fb6a6ac6..c5d97385 100644 --- a/lib/uploadcare/rails/objects/concerns/loadable.rb +++ b/lib/uploadcare/rails/objects/concerns/loadable.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'active_record' +require 'active_model' module Uploadcare module Rails @@ -8,7 +8,7 @@ module Objects # A module that contains methods for attribute assignation and caching module Loadable extend ActiveSupport::Concern - include ::ActiveRecord::AttributeAssignment + include ActiveModel::AttributeAssignment class_methods do def build_cache_key(key) diff --git a/spec/uploadcare/rails/mongoid/mount_uploadcare_file_group_spec.rb b/spec/uploadcare/rails/mongoid/mount_uploadcare_file_group_spec.rb new file mode 100644 index 00000000..ed6bb38d --- /dev/null +++ b/spec/uploadcare/rails/mongoid/mount_uploadcare_file_group_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'uploadcare/rails/mongoid/mount_uploadcare_file_group' + +describe Uploadcare::Rails::Mongoid::MountUploadcareFileGroup do + before do + allow(Rails).to receive(:cache).and_return(double(read: nil, write: nil)) + allow(Uploadcare::Rails).to receive(:configuration).and_return( + double( + store_files_async: false, + delete_files_async: false, + do_not_store: false, + delete_files_after_destroy: false, + cache_files: false, + cache_namespace: 'uploadcare' + ) + ) + + stub_const 'TestModel', Class.new + TestModel.class_eval do + include Mongoid::Document + include Uploadcare::Rails::Mongoid::MountUploadcareFileGroup + extend ActiveModel::Callbacks + + field :cdn_url, type: String + + define_model_callbacks :save, only: :after + + mount_uploadcare_file_group :cdn_url + end + end + + let(:cdn_url) { 'https://api.uploadcare.com/groups/e6c3fb25-0653-454c-9c8e-7e91902bb044~2/' } + let(:model) { TestModel.new(cdn_url: cdn_url) } + let(:files_count) { 2 } + let(:file_attributes) { { cdn_url: cdn_url, id: group_id, files_count: files_count } } + let(:group_id) { 'e6c3fb25-0653-454c-9c8e-7e91902bb044~2' } + + describe '#build_uploadcare_file_group' do + let(:subject) { model.build_uploadcare_file_group(:cdn_url) } + + context 'when cdn_url is empty' do + it 'returns nil' do + model.cdn_url = '' + expect(subject).to be_nil + end + end + + context 'when cdn_url is not empty' do + it 'returns a new Uploadcare::Rails::Group object' do + expect(subject).to be_a(Uploadcare::Rails::Group) + end + + it 'sets the correct attributes on the Uploadcare::Rails::Group object' do + expect(subject.cdn_url).to eq(cdn_url) + expect(subject.id).to eq(group_id) + expect(subject.files_count.to_i).to eq(files_count) + end + end + end + + describe 'Singleton .has_uploadcare_file_group_for_cdn_url?' do + it 'returns true' do + expect(TestModel.has_uploadcare_file_group_for_cdn_url?).to be(true) + end + end + + describe '.mount_uploadcare_file_group' do + let(:attribute) { :cdn_url } + + it 'defines a getter method for the specified attribute' do + expect(model).to respond_to(attribute) + end + + it 'defines a method to store the file group asynchronously' do + expect(model).to respond_to("uploadcare_store_#{attribute}!") + end + + context 'when the file group is present' do + it 'stores the file group synchronously if not configured for async storage' do + allow(Uploadcare::Rails.configuration).to receive(:store_files_async).and_return(false) + expect(Uploadcare::GroupApi).to receive(:store_group).with(group_id) + model.send("uploadcare_store_#{attribute}!") + end + + it 'performs the store job asynchronously if configured' do + allow(Uploadcare::Rails.configuration).to receive(:store_files_async).and_return(true) + expect(Uploadcare::Rails::StoreGroupJob).to receive(:perform_later).with(group_id) + model.send("uploadcare_store_#{attribute}!") + end + end + + context 'when do_not_store configuration is true' do + it 'does not define the after_save callback' do + allow(Uploadcare::Rails.configuration).to receive(:do_not_store).and_return(true) + expect(TestModel).not_to receive(:after_save) + TestModel.mount_uploadcare_file_group(:attribute) + end + end + + context 'when do_not_store configuration is false' do + it 'defines the after_save callback' do + allow(Uploadcare::Rails.configuration).to receive(:do_not_store).and_return(false) + expect(TestModel).to receive(:set_callback).with(:save, :after, :uploadcare_store_cdn_url!) + TestModel.mount_uploadcare_file_group(attribute) + end + end + end +end diff --git a/spec/uploadcare/rails/mongoid/mount_uploadcare_file_spec.rb b/spec/uploadcare/rails/mongoid/mount_uploadcare_file_spec.rb new file mode 100644 index 00000000..470abcb3 --- /dev/null +++ b/spec/uploadcare/rails/mongoid/mount_uploadcare_file_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'uploadcare/rails/mongoid/mount_uploadcare_file' + +describe Uploadcare::Rails::Mongoid::MountUploadcareFile do + before do + allow(Rails).to receive(:cache).and_return(double(read: nil, write: nil)) + allow(Uploadcare::Rails).to receive(:configuration).and_return( + double( + store_files_async: false, + delete_files_async: false, + do_not_store: false, + delete_files_after_destroy: false, + cache_files: false, + cache_namespace: 'uploadcare' + ) + ) + + stub_const 'TestModel', Class.new + TestModel.class_eval do + include Mongoid::Document + include Uploadcare::Rails::Mongoid::MountUploadcareFile + extend ActiveModel::Callbacks + + field :cdn_url, type: String + + define_model_callbacks :save, only: :after + + mount_uploadcare_file :cdn_url + end + end + + let(:cdn_url) { 'https://ucarecdn.com/bec49a46-7a5b-453c-836e-acc894e50c83/5e6ab92e26d54c7292757fe62537905b1.jpg' } + let(:model) { TestModel.new(cdn_url: cdn_url) } + let(:subject) { model.build_uploadcare_file(:cdn_url) } + + describe '#build_uploadcare_file' do + context 'when cdn_url is empty' do + it 'should return nil' do + model.cdn_url = '' + expect(subject).to be_nil + end + end + + context 'when cdn_url is not empty' do + it 'should build Uploadcare::Rails::File object' do + expect(subject).to be_an_instance_of(Uploadcare::Rails::File) + end + + it 'should set cdn_url attribute' do + expect(subject.cdn_url).to eq(cdn_url) + end + + it 'should set uuid attribute' do + expect(subject.uuid).to eq('bec49a46-7a5b-453c-836e-acc894e50c83') + end + end + end + + describe '.mount_uploadcare_file' do + it 'should define uploadcare_store_cdn_url! method' do + expect(model).to respond_to(:uploadcare_store_cdn_url!) + end + + it 'should define uploadcare_delete_cdn_url! method' do + expect(model).to respond_to(:uploadcare_delete_cdn_url!) + end + + context 'when store_files_async configuration is true' do + before do + allow(Uploadcare::Rails.configuration).to receive(:store_files_async).and_return(true) + end + + it 'should enqueue StoreFileJob when calling uploadcare_store_cdn_url!' do + expect(Uploadcare::Rails::StoreFileJob).to receive(:perform_later).with('bec49a46-7a5b-453c-836e-acc894e50c83') + model.uploadcare_store_cdn_url! + end + end + + context 'when store_files_async configuration is false' do + before do + allow(Uploadcare::Rails.configuration).to receive(:store_files_async).and_return(false) + end + + it 'should call Uploadcare::FileApi.store_file when calling uploadcare_store_cdn_url!' do + expect(Uploadcare::FileApi).to receive(:store_file).with('bec49a46-7a5b-453c-836e-acc894e50c83') + model.uploadcare_store_cdn_url! + end + end + + context 'when delete_files_async configuration is true' do + before do + allow(Uploadcare::Rails.configuration).to receive(:delete_files_async).and_return(true) + end + + it 'should enqueue DeleteFileJob when calling uploadcare_delete_cdn_url!' do + expect(Uploadcare::Rails::DeleteFileJob).to receive(:perform_later).with('bec49a46-7a5b-453c-836e-acc894e50c83') + model.uploadcare_delete_cdn_url! + end + end + + context 'when delete_files_async configuration is false' do + before do + allow(Uploadcare::Rails.configuration).to receive(:delete_files_async).and_return(false) + end + + it 'should call Uploadcare::FileApi.delete_file when calling uploadcare_delete_cdn_url!' do + expect(Uploadcare::FileApi).to receive(:delete_file).with('bec49a46-7a5b-453c-836e-acc894e50c83') + model.uploadcare_delete_cdn_url! + end + end + + context 'when do_not_store configuration is false' do + before do + allow(Uploadcare::Rails.configuration).to receive(:do_not_store).and_return(false) + end + + it 'should set callback for saving to call uploadcare_store_cdn_url! if cdn_url attribute changed' do + expect(TestModel).to receive(:set_callback).with(:save, :after, :uploadcare_store_cdn_url!, + if: :cdn_url_changed?) + TestModel.mount_uploadcare_file(:cdn_url) + end + end + + context 'when delete_files_after_destroy configuration is true' do + before do + allow(Uploadcare::Rails.configuration).to receive(:delete_files_after_destroy).and_return(true) + end + + it 'should set callback for destroying to call uploadcare_delete_cdn_url!' do + expect(TestModel).to receive(:set_callback).with(:save, :after, :uploadcare_store_cdn_url!, + if: :cdn_url_changed?) + expect(TestModel).to receive(:set_callback).with(:destroy, :after, :uploadcare_delete_cdn_url!) + TestModel.mount_uploadcare_file(:cdn_url) + end + end + end +end