diff --git a/Gemfile.lock b/Gemfile.lock index 9ff747d..0ed5600 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - fileboost (0.1.5) + fileboost (0.2.0.pre2) activestorage (>= 6.0) GEM diff --git a/README.md b/README.md index 0fc17e8..6912206 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,39 @@ Fileboost is a Rails gem that provides seamless integration with the Fileboost.dev image optimization service. It offers drop-in replacement helpers for Rails' native image helpers with automatic optimization, HMAC authentication, and comprehensive transformation support for ActiveStorage objects. +## Table of Contents + +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) + - [Drop-in Replacement (Recommended)](#drop-in-replacement-recommended) + - [Manual Helper Method](#manual-helper-method) + - [URL Generation](#url-generation) + - [Transformation Options](#transformation-options) + - [Parameter Aliases](#parameter-aliases) + - [ActiveStorage Support](#activestorage-support) + - [ActiveStorage Variants (NEW in v0.2.0)](#activestorage-variants-new-in-v020) + - [Variant Transformation Mapping](#variant-transformation-mapping) + - [Combining Variants with Custom Options](#combining-variants-with-custom-options) + - [Responsive Images](#responsive-images) +- [Error Handling](#error-handling) +- [Security](#security) +- [Development](#development) +- [Testing](#testing) +- [Contributing](#contributing) +- [License](#license) +- [Support](#support) + ## Features -- 🚀 **Drop-in replacement** for Rails `image_tag` and `url_for` helpers +- 🚀 **Drop-in replacement** for Rails `image_tag` with zero code changes (NEW in v0.2.0) +- 🎨 **Full ActiveStorage Variant support** with automatic transformation mapping (NEW in v0.2.0) - 🔒 **Secure HMAC authentication** with Fileboost.dev service - 📱 **ActiveStorage only** - works exclusively with ActiveStorage attachments - 🎛️ **Comprehensive transformations** - resize, quality, format conversion, and more - 🔧 **Simple configuration** - just project ID and token required +- 🔄 **Automatic fallback** - non-ActiveStorage images work exactly as before ## Installation @@ -43,11 +69,59 @@ export FILEBOOST_PROJECT_ID="your-project-id" export FILEBOOST_TOKEN="your-secret-token" ``` +Or configure directly in your initializer: + +```ruby +# config/initializers/fileboost.rb +Fileboost.configure do |config| + config.project_id = ENV["FILEBOOST_PROJECT_ID"] + config.token = ENV["FILEBOOST_TOKEN"] + + # Optional: Enable drop-in replacement for Rails image_tag (default: false) + config.patch_image_tag = true +end +``` + ## Usage -### Basic Image Tag +### Drop-in Replacement (Recommended) + +Enable `patch_image_tag` in your configuration to automatically optimize ActiveStorage images with your existing `image_tag` calls: + +```ruby +# config/initializers/fileboost.rb +Fileboost.configure do |config| + config.project_id = ENV["FILEBOOST_PROJECT_ID"] + config.token = ENV["FILEBOOST_TOKEN"] + config.patch_image_tag = true # Enable automatic optimization +end +``` -Replace `image_tag` with `fileboost_image_tag` for ActiveStorage objects: +With this enabled, your existing Rails code automatically gets Fileboost optimization: + +```erb + +<%= image_tag user.avatar, resize: { w: 300, h: 300 }, alt: "Avatar" %> +<%= image_tag post.featured_image, resize: { width: 800, quality: 85 }, class: "hero" %> + + +<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]), alt: "Thumbnail" %> +<%= image_tag post.image.variant(:thumb), alt: "Post thumbnail" %> + + +<%= image_tag "/assets/logo.png", alt: "Logo" %> +<%= image_tag "https://example.com/image.jpg", alt: "External" %> +``` + +**Benefits:** +- Zero code changes required for existing ActiveStorage images +- Full ActiveStorage variant support with automatic transformation mapping +- Automatic fallback to Rails behavior for non-ActiveStorage assets +- Gradual migration path - enable/disable with single configuration option + +### Manual Helper Method + +Alternatively, use `fileboost_image_tag` explicitly for ActiveStorage objects: ```erb @@ -104,7 +178,7 @@ fileboost_image_tag(image, resize: { w: 400, h: 300, q: 85 }) fileboost_image_tag(image, resize: { width: 400, height: 300, quality: 85 }) ``` -**Note:** Avoid using the `format` parameter. Fileboost automatically selects the optimal image format (WebP, AVIF, JPEG, etc.) based on browser headers and capabilities for the best performance and compatibility. +**🎯 Smart Optimization:** Fileboost's CDN automatically detects and delivers the optimal image format (WebP, AVIF, JPEG, etc.) based on browser capabilities, device type, and connection speed for maximum performance. ### ActiveStorage Support @@ -123,6 +197,63 @@ Works seamlessly with all ActiveStorage attachment types: <%= fileboost_image_tag post.featured_image.blob, resize: { w: 800 } %> ``` +### ActiveStorage Variants (NEW in v0.2.0) + +Fileboost now provides full support for ActiveStorage variants with automatic transformation mapping: + +```erb + +<%= image_tag user.avatar.variant(resize_to_limit: [200, 200]) %> + + +<%= image_tag post.image.variant(resize_to_fit: [400, 300]) %> + + +<%= image_tag hero.banner.variant(resize_to_fill: [800, 400]) %> + + + +<%= image_tag post.image.variant( + resize_to_limit: [600, 400], + quality: 85 +) %> + + + +<%= image_tag user.avatar.variant(:thumb) %> + +``` + +#### Variant Transformation Mapping + +Fileboost automatically maps ActiveStorage variant transformations to optimized URL parameters: + +| ActiveStorage Variant | Fileboost Parameters | Description | +|----------------------|---------------------|-------------| +| `resize_to_limit: [w, h]` | `w=W&h=H&fit=scale-down` | Resize within bounds, preserving aspect ratio | +| `resize_to_fit: [w, h]` | `w=W&h=H&fit=contain` | Resize to fit exactly, with letterboxing if needed | +| `resize_to_fill: [w, h]` | `w=W&h=H&fit=cover` | Resize and crop to fill exactly | +| `resize_and_pad: [w, h]` | `w=W&h=H&fit=pad` | Resize with padding | +| `quality: 85` | `q=85` | JPEG/WebP quality (1-100) | +| `rotate: "-90"` | `r=-90` | Rotation in degrees | + + +#### Combining Variants with Custom Options + +You can combine variant transformations with additional Fileboost options: + +```erb + +<%= image_tag user.avatar.variant(resize_to_limit: [200, 200]), + resize: { blur: 5, brightness: 110 } %> + + + +<%= image_tag post.image.variant(resize_to_limit: [400, 300]), + resize: { w: 500 } %> + +``` + ### Responsive Images Generate multiple sizes for responsive designs: @@ -171,7 +302,7 @@ After checking out the repo, run: ```bash $ bundle install -$ rake test +$ bundle exec rspec ``` To test against the dummy Rails application: @@ -186,7 +317,7 @@ $ rails server Run the test suite: ```bash -$ rake test +$ bundle exec rspec ``` Run RuboCop: diff --git a/lib/fileboost.rb b/lib/fileboost.rb index af508a0..c3da1c2 100644 --- a/lib/fileboost.rb +++ b/lib/fileboost.rb @@ -2,8 +2,10 @@ require "fileboost/config" require "fileboost/error_handler" require "fileboost/signature_generator" +require "fileboost/variant_transformer" require "fileboost/url_builder" require "fileboost/helpers" +require "fileboost/image_tag_patch" require "fileboost/engine" module Fileboost diff --git a/lib/fileboost/config.rb b/lib/fileboost/config.rb index e76dce4..5e40ea7 100644 --- a/lib/fileboost/config.rb +++ b/lib/fileboost/config.rb @@ -1,6 +1,6 @@ module Fileboost class Config - attr_accessor :project_id, :token + attr_accessor :project_id, :token, :patch_image_tag CDN_DOMAIN = "cdn.fileboost.dev" BASE_URL = "https://#{CDN_DOMAIN}" @@ -8,6 +8,7 @@ class Config def initialize @project_id = ENV["FILEBOOST_PROJECT_ID"] @token = ENV["FILEBOOST_TOKEN"] + @patch_image_tag = false end def valid? diff --git a/lib/fileboost/engine.rb b/lib/fileboost/engine.rb index 32adc79..2e1867a 100644 --- a/lib/fileboost/engine.rb +++ b/lib/fileboost/engine.rb @@ -5,6 +5,11 @@ class Engine < ::Rails::Engine initializer "fileboost.action_view" do ActiveSupport.on_load :action_view do include Fileboost::Helpers + + # Conditionally patch image_tag if enabled in configuration + if Fileboost.config.patch_image_tag + prepend Fileboost::ImageTagPatch + end end end end diff --git a/lib/fileboost/image_tag_patch.rb b/lib/fileboost/image_tag_patch.rb new file mode 100644 index 0000000..ffcf593 --- /dev/null +++ b/lib/fileboost/image_tag_patch.rb @@ -0,0 +1,26 @@ +module Fileboost + module ImageTagPatch + def image_tag(source, options = {}) + # If this is an ActiveStorage asset and Fileboost is configured, use fileboost_image_tag + if valid_activestorage_asset?(source) && Fileboost.config.valid? + fileboost_image_tag(source, **options) + else + # Fall back to Rails' original image_tag for all other cases + super(source, options) + end + rescue + # If there's any error with Fileboost processing, fall back to original Rails behavior + super(source, options) + end + + private + + def valid_activestorage_asset?(asset) + return true if asset.is_a?(ActiveStorage::Blob) + return true if asset.is_a?(ActiveStorage::Attached) + return true if asset.is_a?(ActiveStorage::VariantWithRecord) + + false + end + end +end diff --git a/lib/fileboost/url_builder.rb b/lib/fileboost/url_builder.rb index 188e4ef..f2b6e59 100644 --- a/lib/fileboost/url_builder.rb +++ b/lib/fileboost/url_builder.rb @@ -43,7 +43,7 @@ def self.build_url(asset, **options) full_path = "/#{project_id}#{asset_path}" # Extract and normalize transformation parameters - transformation_params = extract_transformation_params(options) + transformation_params = extract_transformation_params(asset, options) # Generate HMAC signature for secure authentication signature = Fileboost::SignatureGenerator.generate( @@ -89,10 +89,16 @@ def self.extract_asset_path(asset) end end - def self.extract_transformation_params(options) + def self.extract_transformation_params(asset, options) params = {} - # Only handle nested resize parameter + # First, extract variant transformations if this is a variant + if asset.is_a?(ActiveStorage::VariantWithRecord) + variant_params = Fileboost::VariantTransformer.transform_variant_params(asset) + params.merge!(variant_params) + end + + # Then handle explicit resize parameter (this can override variant params) if options[:resize].is_a?(Hash) resize_options = options[:resize] resize_options.each do |key, value| @@ -117,9 +123,13 @@ def self.extract_transformation_params(options) def self.normalize_param_value(key, value) case key - when "w", "h", "q", "b", "br", "c", "r" + when "w", "h", "b", "br", "c", "r" # Numeric parameters value.to_i.to_s if value.to_i > 0 + when "q" + # Quality parameter - validate range 1-100 + q = value.to_i + (q > 0 && q <= 100) ? q.to_s : nil when "f" # Format parameter - validate against common formats valid_formats = %w[webp jpeg jpg png gif avif] diff --git a/lib/fileboost/variant_transformer.rb b/lib/fileboost/variant_transformer.rb new file mode 100644 index 0000000..0ab4012 --- /dev/null +++ b/lib/fileboost/variant_transformer.rb @@ -0,0 +1,130 @@ +module Fileboost + # Maps ActiveStorage variant transformations to Fileboost URL parameters + class VariantTransformer + # Maps ActiveStorage transformation operations to Fileboost parameters + TRANSFORMATION_MAPPING = { + # Resize operations + resize_to_limit: { fit: "scale-down" }, + resize_to_fit: { fit: "contain" }, + resize_to_fill: { fit: "cover" }, + resize_and_pad: { fit: "pad" }, + + # Quality settings + quality: { param: "q" }, + + # Format settings + format: { param: "f" }, + + # Rotation + rotate: { param: "r" }, + + # Crop operations - need special handling + crop: { special: :crop_handler } + }.freeze + + # Convert ActiveStorage variant transformations to Fileboost parameters + def self.transform_variant_params(variant) + return {} unless variant.respond_to?(:variation) + + transformations = variant.variation.transformations + params = {} + + transformations.each do |operation, value| + case operation + when :resize_to_limit, :resize_to_fit, :resize_to_fill, :resize_and_pad + resize_params = handle_resize_operation(operation, value) + params.merge!(resize_params) + + when :quality + params["q"] = normalize_quality(value) + + when :format + params["f"] = normalize_format(value) + + when :rotate + params["r"] = normalize_rotation(value) + + when :crop + crop_params = handle_crop_operation(value) + params.merge!(crop_params) if crop_params + + # Add more transformations as needed + end + end + + params + end + + private + + # Handle resize operations (resize_to_limit, resize_to_fit, etc.) + def self.handle_resize_operation(operation, dimensions) + return {} unless dimensions.is_a?(Array) && dimensions.length >= 2 + + width, height = dimensions[0], dimensions[1] + params = {} + + # Set dimensions + params["w"] = width.to_s if width && width > 0 + params["h"] = height.to_s if height && height > 0 + + # Set fit parameter based on resize operation + fit_mapping = TRANSFORMATION_MAPPING[operation] + params["fit"] = fit_mapping[:fit] if fit_mapping && fit_mapping[:fit] + + params + end + + # Handle crop operations + def self.handle_crop_operation(crop_params) + # Crop can be in different formats depending on the processor + # For now, we'll handle simple array format: [x, y, width, height] + if crop_params.is_a?(Array) && crop_params.length == 4 + x, y, w, h = crop_params + return { "crop" => "#{x},#{y},#{w},#{h}" } + end + + # Could extend this for other crop formats + nil + end + + # Normalize quality value (0-100) + def self.normalize_quality(quality) + q = quality.to_i + return nil if q <= 0 || q > 100 + q.to_s + end + + # Normalize format value + def self.normalize_format(format) + # Convert to string and lowercase + format_str = format.to_s.downcase + + # Map common format variations + case format_str + when "jpg", "jpeg" + "jpg" + when "png" + "png" + when "webp" + "webp" + when "avif" + "avif" + when "gif" + "gif" + else + # If it's already a recognized format, use it + valid_formats = %w[webp jpeg jpg png gif avif] + valid_formats.include?(format_str) ? format_str : nil + end + end + + # Normalize rotation value + def self.normalize_rotation(rotation) + # Rotation should be a number (degrees) + r = rotation.to_s.gsub(/[^\d\-]/, "") # Remove non-numeric chars except minus + return nil if r.empty? + r + end + end +end diff --git a/lib/fileboost/version.rb b/lib/fileboost/version.rb index 95ba749..626d2b8 100644 --- a/lib/fileboost/version.rb +++ b/lib/fileboost/version.rb @@ -1,3 +1,3 @@ module Fileboost - VERSION = "0.1.5" + VERSION = "0.2.0.pre2" end diff --git a/lib/generators/fileboost/templates/INSTALL.md b/lib/generators/fileboost/templates/INSTALL.md index 6aeb06f..163433e 100644 --- a/lib/generators/fileboost/templates/INSTALL.md +++ b/lib/generators/fileboost/templates/INSTALL.md @@ -9,33 +9,24 @@ config/initializers/fileboost.rb Next steps: -1. Set your environment variables: +1. Register an account at: https://fileboost.dev + Get your project ID and secret token + +2. Set your environment variables: export FILEBOOST_PROJECT_ID="your-project-id" export FILEBOOST_TOKEN="your-secret-token" -2. Or configure directly in the initializer file: - Edit config/initializers/fileboost.rb with your credentials - -3. Start using Fileboost helpers in your views: - - - - <%= fileboost_image_tag user.avatar, alt: "Avatar", resize: {width: 100, height: 100, fit: "cover"} %> - - +3. Enable drop-in replacement (recommended): + Edit config/initializers/fileboost.rb and set: + config.patch_image_tag = true - <%= fileboost_url_for post.image %> +4. Your existing image_tag calls now work with ActiveStorage images: + <%= image_tag user.avatar %> + <%= image_tag post.image.variant(resize_to_limit: [300, 200]) %> -4. Supported transformation options: - - width, height (or w, h) - - quality (or q): 1-100 - - format (or f): webp, jpeg, png, gif, avif - - blur (or b): 0-100 - - brightness (or br): 0-200 - - contrast (or c): 0-200 - - rotation (or r): 0-359 - - fit: cover, contain, fill, scale-down, crop, pad +5. Or use explicit helpers: + <%= fileboost_image_tag user.avatar, resize: {w: 300, h: 200, fit: "cover"} %> -For more information, visit: https://fileboost.dev +For documentation, visit: https://github.com/bilalbudhani/fileboost =============================================================================== diff --git a/lib/generators/fileboost/templates/fileboost.rb b/lib/generators/fileboost/templates/fileboost.rb index 6ce01ba..b53ec5e 100644 --- a/lib/generators/fileboost/templates/fileboost.rb +++ b/lib/generators/fileboost/templates/fileboost.rb @@ -16,4 +16,10 @@ # You can also set this via the FILEBOOST_TOKEN environment variable # IMPORTANT: Keep this secret secure and never commit it to version control config.token = ENV["FILEBOOST_TOKEN"] # || "your-secret-token" + + # Drop-in replacement for Rails image_tag helper + # When enabled, image_tag will automatically use Fileboost optimization + # for ActiveStorage assets while falling back to standard Rails behavior + # for other image sources. Defaults to false. + # config.patch_image_tag = true end diff --git a/spec/fileboost/config_spec.rb b/spec/fileboost/config_spec.rb index 2d262f0..e702d68 100644 --- a/spec/fileboost/config_spec.rb +++ b/spec/fileboost/config_spec.rb @@ -10,10 +10,17 @@ expect(config.project_id).to eq("test_project") expect(config.token).to eq("test_token") + expect(config.patch_image_tag).to eq(false) ENV.delete("FILEBOOST_PROJECT_ID") ENV.delete("FILEBOOST_TOKEN") end + + it "sets patch_image_tag to false by default" do + config = Fileboost::Config.new + + expect(config.patch_image_tag).to eq(false) + end end describe "#valid?" do diff --git a/spec/fileboost/helpers_spec.rb b/spec/fileboost/helpers_spec.rb index d168002..15c729b 100644 --- a/spec/fileboost/helpers_spec.rb +++ b/spec/fileboost/helpers_spec.rb @@ -37,6 +37,29 @@ expect(url).to include("w=300") end + it "generates URL for ActiveStorage variant" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + + url = fileboost_url_for(variant) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=100") + expect(url).to include("h=100") + expect(url).to include("fit=scale-down") + end + + it "generates URL for named variants" do + user.avatar.attach(blob) + thumb_variant = user.avatar.variant(:thumb) + + url = fileboost_url_for(thumb_variant) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=100") + expect(url).to include("h=100") + expect(url).to include("fit=scale-down") + end + it "raises ArgumentError for invalid asset type" do expect { fileboost_url_for("invalid", resize: { w: 300 }) @@ -60,6 +83,25 @@ expect(result).to eq('test') end + + it "generates image tag for variants" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + allow(self).to receive(:image_tag).and_return('test') + + result = fileboost_image_tag(variant, alt: "test") + + expect(result).to eq('test') + end + + it "generates image tag for named variants" do + user.avatar.attach(blob) + thumb_variant = user.avatar.variant(:thumb) + allow(self).to receive(:image_tag).and_return('test') + + result = fileboost_image_tag(thumb_variant, alt: "test") + + expect(result).to eq('test') + end end describe "#fileboost_responsive_urls" do diff --git a/spec/fileboost/image_tag_patch_spec.rb b/spec/fileboost/image_tag_patch_spec.rb new file mode 100644 index 0000000..151b7ea --- /dev/null +++ b/spec/fileboost/image_tag_patch_spec.rb @@ -0,0 +1,150 @@ +require "spec_helper" + +RSpec.describe Fileboost::ImageTagPatch do + # Create a test class that includes both helpers and patch + let(:view_class) do + Class.new(ActionView::Base) do + include Fileboost::Helpers + prepend Fileboost::ImageTagPatch + end + end + let(:view_instance) { view_class.new(ActionView::LookupContext.new([]), {}, nil) } + + let(:user) { User.create!(name: "Test User") } + let(:blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("fake image data"), + filename: "test.jpg", + content_type: "image/jpeg" + ) + end + + before do + Fileboost.configure do |config| + config.project_id = "test_project" + config.token = "test_token" + config.patch_image_tag = true + end + end + + describe "#image_tag with patch enabled" do + context "with ActiveStorage assets" do + it "uses fileboost_image_tag for ActiveStorage::Blob" do + expect(view_instance).to receive(:fileboost_image_tag).with(blob, alt: "test").and_return('test') + + result = view_instance.image_tag(blob, alt: "test") + + expect(result).to eq('test') + end + + it "uses fileboost_image_tag for ActiveStorage::Attached" do + user.avatar.attach(blob) + expect(view_instance).to receive(:fileboost_image_tag).with(user.avatar, alt: "avatar").and_return('avatar') + + result = view_instance.image_tag(user.avatar, alt: "avatar") + + expect(result).to eq('avatar') + end + + it "uses fileboost_image_tag for ActiveStorage::VariantWithRecord" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + expect(view_instance).to receive(:fileboost_image_tag).with(variant, alt: "variant").and_return('variant') + + result = view_instance.image_tag(variant, alt: "variant") + + expect(result).to eq('variant') + end + + it "uses fileboost_image_tag for named variants" do + user.avatar.attach(blob) + thumb_variant = user.avatar.variant(:thumb) + expect(view_instance).to receive(:fileboost_image_tag).with(thumb_variant, alt: "thumb").and_return('thumb') + + result = view_instance.image_tag(thumb_variant, alt: "thumb") + + expect(result).to eq('thumb') + end + + it "passes through all options to fileboost_image_tag" do + options = { alt: "test", class: "hero-image", data: { id: "123" }, resize: { w: 300 } } + expect(view_instance).to receive(:fileboost_image_tag).with(blob, **options).and_return('') + + view_instance.image_tag(blob, **options) + end + end + + context "with non-ActiveStorage assets" do + it "uses original Rails image_tag for string paths" do + result = view_instance.image_tag("/path/to/image.jpg", alt: "test") + + # Should call Rails' original image_tag which creates a standard img tag + expect(result).to include('src="/path/to/image.jpg"') + expect(result).to include('alt="test"') + end + + it "uses original Rails image_tag for URLs" do + result = view_instance.image_tag("https://example.com/image.jpg", alt: "external") + + expect(result).to include('src="https://example.com/image.jpg"') + expect(result).to include('alt="external"') + end + end + + context "configuration validation" do + it "does not call fileboost_image_tag when config is invalid" do + Fileboost.configure do |config| + config.project_id = "" + config.token = "test_token" + config.patch_image_tag = true + end + + expect(view_instance).not_to receive(:fileboost_image_tag) + + # Test that the method correctly skips fileboost processing + # We don't test the actual super() call since it requires complex ActionView setup + expect(view_instance.send(:valid_activestorage_asset?, blob)).to be true + expect(Fileboost.config.valid?).to be false + end + end + end + + describe "configuration behavior" do + context "when patch_image_tag is false" do + let(:unpatch_view_class) do + Class.new(ActionView::Base) do + include Fileboost::Helpers + # Do not include the patch module + end + end + let(:unpatch_view_instance) { unpatch_view_class.new(ActionView::LookupContext.new([]), {}, nil) } + + it "does not interfere with original image_tag behavior" do + # Since patch is not included, this should work as normal Rails image_tag + result = unpatch_view_instance.image_tag("/normal/image.jpg", alt: "normal") + + expect(result).to include('src="/normal/image.jpg"') + expect(result).to include('alt="normal"') + end + end + end + + describe "#valid_activestorage_asset?" do + it "returns true for ActiveStorage::Blob" do + expect(view_instance.send(:valid_activestorage_asset?, blob)).to be true + end + + it "returns true for ActiveStorage::Attached" do + user.avatar.attach(blob) + expect(view_instance.send(:valid_activestorage_asset?, user.avatar)).to be true + end + + it "returns false for strings" do + expect(view_instance.send(:valid_activestorage_asset?, "/path/to/image.jpg")).to be false + end + + it "returns false for other objects" do + expect(view_instance.send(:valid_activestorage_asset?, { url: "test" })).to be false + expect(view_instance.send(:valid_activestorage_asset?, 123)).to be false + end + end +end diff --git a/spec/fileboost/url_builder_spec.rb b/spec/fileboost/url_builder_spec.rb index 44c7fb5..fe10f59 100644 --- a/spec/fileboost/url_builder_spec.rb +++ b/spec/fileboost/url_builder_spec.rb @@ -10,6 +10,10 @@ ) end + let(:variant) do + blob.variant(resize_to_limit: [ 300, 200 ]) + end + before do Fileboost.configure do |config| config.project_id = "test_project" @@ -17,6 +21,10 @@ end end + after do + blob.purge + end + describe ".build_url" do context "with valid ActiveStorage::Blob" do it "builds a URL with signature" do @@ -42,6 +50,53 @@ end end + context "with ActiveStorage variant" do + it "builds a URL with variant transformations" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + + url = Fileboost::UrlBuilder.build_url(variant) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=100") + expect(url).to include("h=100") + expect(url).to include("fit=scale-down") + expect(url).to include("sig=") + end + + it "merges variant transformations with explicit resize options" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + + url = Fileboost::UrlBuilder.build_url(variant, resize: { q: 85 }) + + expect(url).to include("w=100") + expect(url).to include("h=100") + expect(url).to include("fit=scale-down") + expect(url).to include("q=85") + end + + it "allows explicit resize options to override variant parameters" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + + url = Fileboost::UrlBuilder.build_url(variant, resize: { w: 200 }) + + expect(url).to include("w=200") # overridden + expect(url).to include("h=100") # from variant + expect(url).to include("fit=scale-down") # from variant + end + + it "builds a URL for named variants" do + user.avatar.attach(blob) + thumb_variant = user.avatar.variant(:thumb) + + url = Fileboost::UrlBuilder.build_url(thumb_variant) + + expect(url).to include("https://cdn.fileboost.dev") + expect(url).to include("w=100") + expect(url).to include("h=100") + expect(url).to include("fit=scale-down") + end + end + context "with invalid configuration" do it "raises ConfigurationError when config is invalid" do Fileboost.config.project_id = "" @@ -59,46 +114,41 @@ }.to raise_error(Fileboost::AssetPathExtractionError) end end - end - describe ".extract_transformation_params" do - it "extracts and normalizes resize parameters" do - options = { resize: { width: 300, height: 200, quality: 85, format: "webp" } } + context "with parameter normalization" do + it "normalizes resize parameters correctly" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { width: 300, height: 200, quality: 85, format: "webp" }) - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) + expect(url).to include("w=300") + expect(url).to include("h=200") + expect(url).to include("q=85") + expect(url).to include("f=webp") + end - expect(params["w"]).to eq("300") - expect(params["h"]).to eq("200") - expect(params["q"]).to eq("85") - expect(params["f"]).to eq("webp") - end + it "ignores invalid parameters" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { invalid_param: "value", w: 300 }) - it "ignores invalid parameters" do - options = { resize: { invalid_param: "value", w: 300 } } + expect(url).to include("w=300") + expect(url).not_to include("invalid_param") + end - params = Fileboost::UrlBuilder.send(:extract_transformation_params, options) + it "rejects invalid quality values" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { q: 150 }) - expect(params).not_to have_key("invalid_param") - expect(params["w"]).to eq("300") - end - end + expect(url).not_to include("q=150") + end - describe ".normalize_param_value" do - it "normalizes numeric parameters" do - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 300)).to eq("300") - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "q", "85")).to eq("85") - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 0)).to be_nil - end + it "rejects invalid format values" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { f: "invalid" }) - it "validates format parameter" do - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "webp")).to eq("webp") - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "invalid")).to be_nil - end + expect(url).not_to include("f=invalid") + end - it "validates fit parameter" do - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "cover")).to eq("cover") - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "scale_down")).to eq("scale-down") - expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "invalid")).to be_nil + it "normalizes fit parameter correctly" do + url = Fileboost::UrlBuilder.build_url(blob, resize: { fit: "scale_down" }) + + expect(url).to include("fit=scale-down") + end end end end diff --git a/spec/fileboost/variant_transformer_spec.rb b/spec/fileboost/variant_transformer_spec.rb new file mode 100644 index 0000000..3fe6648 --- /dev/null +++ b/spec/fileboost/variant_transformer_spec.rb @@ -0,0 +1,199 @@ +require "spec_helper" + +RSpec.describe Fileboost::VariantTransformer do + let(:user) { User.create!(name: "Test User") } + let(:blob) do + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("fake image data"), + filename: "test.jpg", + content_type: "image/jpeg" + ) + end + + after do + blob.purge + end + + describe ".transform_variant_params" do + context "with resize_to_limit" do + it "transforms resize_to_limit to w, h, and fit=scale-down" do + variant = blob.variant(resize_to_limit: [ 100, 100 ]) + params = described_class.transform_variant_params(variant) + + expect(params["w"]).to eq("100") + expect(params["h"]).to eq("100") + expect(params["fit"]).to eq("scale-down") + end + end + + context "with resize_to_fit" do + it "transforms resize_to_fit to w, h, and fit=contain" do + variant = blob.variant(resize_to_fit: [ 200, 150 ]) + params = described_class.transform_variant_params(variant) + + expect(params["w"]).to eq("200") + expect(params["h"]).to eq("150") + expect(params["fit"]).to eq("contain") + end + end + + context "with resize_to_fill" do + it "transforms resize_to_fill to w, h, and fit=cover" do + variant = blob.variant(resize_to_fill: [ 300, 200 ]) + params = described_class.transform_variant_params(variant) + + expect(params["w"]).to eq("300") + expect(params["h"]).to eq("200") + expect(params["fit"]).to eq("cover") + end + end + + context "with quality" do + it "transforms quality to q parameter" do + variant = blob.variant(quality: 85) + params = described_class.transform_variant_params(variant) + + expect(params["q"]).to eq("85") + end + + it "rejects invalid quality values" do + variant = blob.variant(quality: 150) + params = described_class.transform_variant_params(variant) + + expect(params["q"]).to be_nil + end + end + + context "with format" do + it "transforms format to f parameter" do + variant = blob.variant(format: :webp) + params = described_class.transform_variant_params(variant) + + expect(params["f"]).to eq("webp") + end + + it "normalizes jpeg to jpg" do + variant = blob.variant(format: :jpeg) + params = described_class.transform_variant_params(variant) + + expect(params["f"]).to eq("jpg") + end + end + + context "with rotation" do + it "transforms rotate parameter" do + variant = blob.variant(rotate: "-90") + params = described_class.transform_variant_params(variant) + + expect(params["r"]).to eq("-90") + end + end + + context "with multiple transformations" do + it "transforms multiple parameters correctly" do + variant = blob.variant( + resize_to_limit: [ 400, 300 ], + quality: 90, + format: :webp + ) + params = described_class.transform_variant_params(variant) + + expect(params["w"]).to eq("400") + expect(params["h"]).to eq("300") + expect(params["fit"]).to eq("scale-down") + expect(params["q"]).to eq("90") + expect(params["f"]).to eq("webp") + end + end + + context "with named variants" do + it "transforms named variant (:thumb) correctly" do + user.avatar.attach(blob) + thumb_variant = user.avatar.variant(:thumb) + params = described_class.transform_variant_params(thumb_variant) + + # Based on user.rb: attachable.variant :thumb, resize_to_limit: [100, 100] + expect(params["w"]).to eq("100") + expect(params["h"]).to eq("100") + expect(params["fit"]).to eq("scale-down") + end + end + + context "with non-variant objects" do + it "returns empty hash for blobs" do + params = described_class.transform_variant_params(blob) + expect(params).to eq({}) + end + + it "returns empty hash for strings" do + params = described_class.transform_variant_params("not_a_variant") + expect(params).to eq({}) + end + end + end + + context "with edge cases and validation" do + it "handles invalid quality values by excluding them" do + variant = blob.variant(quality: 0) + params = described_class.transform_variant_params(variant) + + expect(params["q"]).to be_nil + end + + it "handles negative quality values by excluding them" do + variant = blob.variant(quality: -10) + params = described_class.transform_variant_params(variant) + + expect(params["q"]).to be_nil + end + + it "handles quality values over 100 by excluding them" do + variant = blob.variant(quality: 150) + params = described_class.transform_variant_params(variant) + + expect(params["q"]).to be_nil + end + + it "normalizes JPEG format to jpg" do + variant = blob.variant(format: "JPEG") + params = described_class.transform_variant_params(variant) + + expect(params["f"]).to eq("jpg") + end + + it "handles invalid format values by excluding them" do + variant = blob.variant(format: "invalid") + params = described_class.transform_variant_params(variant) + + expect(params["f"]).to be_nil + end + + it "handles unknown format values by excluding them" do + variant = blob.variant(format: "unknown") + params = described_class.transform_variant_params(variant) + + expect(params["f"]).to be_nil + end + + it "normalizes rotation with degrees suffix" do + variant = blob.variant(rotate: "-90deg") + params = described_class.transform_variant_params(variant) + + expect(params["r"]).to eq("-90") + end + + it "normalizes rotation with function syntax" do + variant = blob.variant(rotate: "rotate(180)") + params = described_class.transform_variant_params(variant) + + expect(params["r"]).to eq("180") + end + + it "handles numeric rotation values" do + variant = blob.variant(rotate: -90) + params = described_class.transform_variant_params(variant) + + expect(params["r"]).to eq("-90") + end + end +end diff --git a/spec/internal/app/models/user.rb b/spec/internal/app/models/user.rb index 74ea160..d1e978b 100644 --- a/spec/internal/app/models/user.rb +++ b/spec/internal/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord - has_one_attached :avatar + has_one_attached :avatar do |attachable| + attachable.variant :thumb, resize_to_limit: [ 100, 100 ] + end has_many_attached :images end