diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index dcfadc1e..61110a78 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -34,6 +34,14 @@ jobs: env: UPLOADCARE_PUBLIC_KEY: demopublickey UPLOADCARE_SECRET_KEY: demoprivatekey + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + if: matrix.ruby-version == '3.3' + with: + file: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false style-check: runs-on: ubuntu-latest diff --git a/.rubocop.yml b/.rubocop.yml index e1fb9093..9c60e9a8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,12 +10,6 @@ Layout/LineLength: Lint/IneffectiveAccessModifier: Enabled: false -Style/HashTransformKeys: - Exclude: - - 'lib/uploadcare/entity/decorator/paginator.rb' - - 'lib/uploadcare/client/conversion/video_conversion_client.rb' - - 'lib/uploadcare/entity/file.rb' - Metrics/BlockLength: Exclude: - 'bin/' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7ea875ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Uploadcare Ruby SDK - Ruby client library for Uploadcare's Upload and REST APIs, providing file upload, management, and transformation capabilities. + +## Development Commands + +### Environment Setup +- Ruby 3.0+ required (use mise for version management: `mise use ruby@latest`) +- Install dependencies: `bundle install` +- Add `gem 'base64'` to Gemfile if using Ruby 3.4+ to avoid vcr gem warnings + +### Testing +- Run all tests: `bundle exec rake spec` or `bundle exec rspec` +- Run specific test file: `bundle exec rspec spec/uploadcare/resources/file_spec.rb` +- Run with documentation format: `bundle exec rspec --format documentation` +- Run with fail-fast: `bundle exec rspec --fail-fast` +- Test environment variables required: + - `UPLOADCARE_PUBLIC_KEY=demopublickey` + - `UPLOADCARE_SECRET_KEY=demoprivatekey` + +### Code Quality +- Run linter: `bundle exec rubocop` +- Run linter with auto-fix: `bundle exec rubocop -a` +- Run all checks (tests + linter): `bundle exec rake` + +## Architecture Overview + +### Core Module Structure +The gem uses Zeitwerk autoloading with collapsed directories for resources and clients: + +- **lib/uploadcare.rb** - Main module, configures Zeitwerk autoloading +- **lib/uploadcare/configuration.rb** - Configuration management +- **lib/uploadcare/api.rb** - Main API interface (deprecated pattern, use resources directly) +- **lib/uploadcare/client.rb** - New client pattern for API interactions + +### Resource Layer (lib/uploadcare/resources/) +Domain objects representing Uploadcare entities: +- **file.rb** - File upload/management operations +- **group.rb** - File group operations +- **webhook.rb** - Webhook management +- **uploader.rb** - Upload coordination +- **paginated_collection.rb** - Pagination support for list operations +- **batch_file_result.rb** - Batch operation results +- **add_ons.rb** - Add-on services (AWS Rekognition, ClamAV, Remove.bg) +- **document_converter.rb** - Document conversion operations +- **video_converter.rb** - Video conversion operations + +### Client Layer (lib/uploadcare/clients/) +HTTP client implementations for API communication: +- **rest_client.rb** - Base REST API client +- **upload_client.rb** - Upload API client +- **multipart_upload_client.rb** - Multipart upload handling +- **uploader_client.rb** - Upload coordination client +- **file_client.rb** - File management endpoints +- **group_client.rb** - Group management endpoints +- **webhook_client.rb** - Webhook endpoints +- **project_client.rb** - Project info endpoints + +### Middleware Layer (lib/uploadcare/middleware/) +Request/response processing: +- **base.rb** - Base middleware class +- **retry.rb** - Retry logic for failed requests +- **logger.rb** - Request/response logging + +### Error Handling +- **lib/uploadcare/error_handler.rb** - Central error parsing and handling +- **lib/uploadcare/exception/** - Custom exception types + - **request_error.rb** - Base request errors + - **auth_error.rb** - Authentication errors + - **throttle_error.rb** - Rate limiting errors + - **retry_error.rb** - Retry exhaustion errors + +### Utilities +- **lib/uploadcare/authenticator.rb** - Request signing and authentication +- **lib/uploadcare/url_builder.rb** - CDN URL generation with transformations +- **lib/uploadcare/signed_url_generators/** - Secure URL generation (Akamai) +- **lib/uploadcare/throttle_handler.rb** - Rate limit handling + +## Key Design Patterns + +1. **Resource-Client Separation**: Resources handle business logic, clients handle HTTP communication +2. **Zeitwerk Autoloading**: Uses collapsed directories for cleaner require structure +3. **Middleware Pattern**: Extensible request/response processing pipeline +4. **Result Objects**: Many operations return Success/Failure result objects +5. **Lazy Loading**: Paginated collections fetch data on demand + +## API Configuration + +Configuration can be set via: +- Environment variables: `UPLOADCARE_PUBLIC_KEY`, `UPLOADCARE_SECRET_KEY` +- Code: `Uploadcare.config.public_key = "key"` +- Per-request: Pass config to individual resource methods + +## Testing Approach + +- Uses RSpec for testing +- VCR for recording/replaying HTTP interactions +- SimpleCov for code coverage reporting +- Tests are in `spec/uploadcare/` mirroring lib structure +- Fixtures and cassettes in `spec/fixtures/` + +## Common Development Tasks + +### Adding New API Endpoints +1. Create/update client in `lib/uploadcare/clients/` +2. Create/update resource in `lib/uploadcare/resources/` +3. Add corresponding specs in `spec/uploadcare/` +4. Update README.md with usage examples + +### Handling API Responses +- Use `Uploadcare::ErrorHandler` for error parsing +- Return result objects for operations that can fail +- Parse JSON responses into Ruby objects/hashes + +### Working with Batch Operations +- Use `BatchFileResult` for batch store/delete results +- Handle both successful results and problem items +- Follow pattern in `File.batch_store` and `File.batch_delete` \ No newline at end of file diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 00000000..f9466cde --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,744 @@ +# Uploadcare Ruby SDK Examples + +This document provides comprehensive examples for all features of the Uploadcare Ruby SDK v3.5+. + +## Table of Contents +- [Configuration](#configuration) +- [File Upload](#file-upload) +- [File Management](#file-management) +- [Batch Operations](#batch-operations) +- [Groups](#groups) +- [Webhooks](#webhooks) +- [Add-Ons](#add-ons) +- [Conversions](#conversions) +- [Secure Delivery](#secure-delivery) +- [Error Handling](#error-handling) + +## Configuration + +### Basic Configuration +```ruby +# Option 1: Configure globally +Uploadcare.configure do |config| + config.public_key = 'your_public_key' + config.secret_key = 'your_secret_key' + config.max_request_tries = 5 # optional + config.base_request_sleep = 1 # optional + config.max_request_sleep = 60 # optional +end + +# Option 2: Environment variables (automatic) +# Set UPLOADCARE_PUBLIC_KEY and UPLOADCARE_SECRET_KEY + +# Option 3: Per-client configuration +client = Uploadcare.client( + public_key: 'your_public_key', + secret_key: 'your_secret_key' +) +``` + +## File Upload + +### Basic Upload +```ruby +# Upload from file +file = File.open('path/to/image.jpg') +uploaded = Uploadcare::Uploader.upload(file, store: 'auto') +puts uploaded.uuid +puts uploaded.original_file_url + +# Upload from URL +uploaded = Uploadcare::Uploader.upload_from_url('https://example.com/image.jpg') + +# Upload from string/IO +require 'stringio' +io = StringIO.new("Hello, World!") +uploaded = Uploadcare::Uploader.upload(io, store: true) +``` + +### Upload with Metadata +```ruby +file = File.open('document.pdf') +uploaded = Uploadcare::Uploader.upload( + file, + store: true, + metadata: { + department: 'finance', + document_type: 'invoice', + year: '2024' + } +) +``` + +### Multipart Upload for Large Files +```ruby +large_file = File.open('video.mp4') # > 100MB +uploaded = Uploadcare::Uploader.multipart_upload(large_file, store: true) do |progress_info| + percent = (progress_info[:offset].to_f / progress_info[:object].size * 100).round(2) + puts "Upload progress: #{percent}%" +end +``` + +### Upload Multiple Files +```ruby +files = [ + File.open('image1.jpg'), + File.open('image2.jpg'), + File.open('document.pdf') +] +results = Uploadcare::Uploader.upload_files(files, store: 'auto') +results.each { |file| puts "Uploaded: #{file.uuid}" } +``` + +### Async Upload from URL +```ruby +# Start async upload +token = Uploadcare::Uploader.upload_from_url('https://example.com/large-file.zip', async: true) + +# Check status +status = Uploadcare::Uploader.get_upload_from_url_status(token) +if status[:status] == 'success' + puts "File uploaded: #{status[:uuid]}" +elsif status[:status] == 'error' + puts "Upload failed: #{status[:error]}" +else + puts "Upload in progress..." +end +``` + +## File Management + +### Get File Information +```ruby +# Using File resource +file = Uploadcare::File.new(uuid: 'dc99200d-9bd6-4b43-bfa9-aa7bfaefca40') +info = file.info(include: 'appdata') + +puts info[:original_filename] +puts info[:size] +puts info[:mime_type] +puts info[:datetime_uploaded] + +# Access metadata +puts info[:metadata] + +# Access app data (if any add-ons were applied) +puts info[:appdata] +``` + +### Store and Delete Files +```ruby +# Store a file permanently +file = Uploadcare::File.new(uuid: 'FILE_UUID') +stored = file.store +puts "Stored at: #{stored.datetime_stored}" + +# Delete a file +deleted = file.delete +puts "Deleted at: #{deleted.datetime_removed}" + +# Note: Deleted file metadata is kept permanently +``` + +### List Files with Filtering +```ruby +# List all stored files +files = Uploadcare::File.list( + limit: 100, + stored: true, + ordering: '-datetime_uploaded' +) + +files.each do |file| + puts "#{file.original_filename} - #{file.size} bytes" +end + +# List files uploaded after specific date +files = Uploadcare::File.list( + from: '2024-01-01T00:00:00Z', + ordering: 'datetime_uploaded' +) + +# Pagination +page1 = Uploadcare::File.list(limit: 10) +page2 = page1.next_page if page1.next_page +``` + +### Copy Files +```ruby +# Local copy (within same project) +source_uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +copied = Uploadcare::File.local_copy(source_uuid, store: true) +puts "New file UUID: #{copied.uuid}" + +# Remote copy (to external storage) +result = Uploadcare::File.remote_copy( + source_uuid, + 'my-s3-storage', # preconfigured storage name + make_public: true +) +puts "File copied to: #{result}" +``` + +### File Metadata Management +```ruby +uuid = 'FILE_UUID' + +# Get all metadata +metadata = Uploadcare::FileMetadata.index(uuid) +puts metadata + +# Get specific metadata value +value = Uploadcare::FileMetadata.show(uuid, 'department') +puts "Department: #{value}" + +# Update metadata +Uploadcare::FileMetadata.update(uuid, 'status', 'approved') + +# Delete metadata key +Uploadcare::FileMetadata.delete(uuid, 'temp_flag') +``` + +## Batch Operations + +### Batch Store +```ruby +uuids = [ + 'dc99200d-9bd6-4b43-bfa9-aa7bfaefca40', + 'a4b9db2f-1591-4f4c-8f68-94018924525d', + '8f64f313-e6b1-4731-96c0-6751f1e7a50a' +] + +result = Uploadcare::File.batch_store(uuids) + +if result.status == 'success' + result.result.each do |file| + puts "Stored: #{file.uuid}" + end +end + +# Handle any problems +result.problems.each do |uuid, error| + puts "Failed to store #{uuid}: #{error}" +end +``` + +### Batch Delete +```ruby +uuids = ['uuid1', 'uuid2', 'uuid3'] +result = Uploadcare::File.batch_delete(uuids) + +if result.status == 'success' + puts "Successfully deleted #{result.result.count} files" +end +``` + +## Groups + +### Create and Manage Groups +```ruby +# Create a group from file UUIDs +file_uuids = [ + '134dc30c-093e-4f48-a5b9-966fe9cb1d01', + '134dc30c-093e-4f48-a5b9-966fe9cb1d02' +] +group = Uploadcare::Group.create(file_uuids) +puts "Group created: #{group.id}" + +# Get group info +group = Uploadcare::Group.new(uuid: 'GROUP_UUID~2') +info = group.info +puts "Files in group: #{info[:files_count]}" + +# Store all files in group +Uploadcare::Group.store(group.id) + +# Delete group (files remain) +group.delete +``` + +### List Groups +```ruby +groups = Uploadcare::Group.list +groups.each do |group| + puts "Group #{group.id} has #{group.files_count} files" +end +``` + +## Webhooks + +### Create and Manage Webhooks +```ruby +# Create webhook +webhook = Uploadcare::Webhook.create( + target_url: 'https://example.com/webhook/uploadcare', + event: 'file.uploaded', + is_active: true, + signing_secret: 'webhook_secret_key' +) +puts "Webhook created with ID: #{webhook.id}" + +# Update webhook +updated = Uploadcare::Webhook.update( + webhook.id, + target_url: 'https://example.com/webhook/new', + event: 'file.stored', + is_active: true +) + +# Or update instance +webhook.update( + target_url: 'https://example.com/webhook/updated', + is_active: false +) + +# List all webhooks +webhooks = Uploadcare::Webhook.list +webhooks.each do |w| + puts "#{w.event} -> #{w.target_url} (#{w.is_active ? 'active' : 'inactive'})" +end + +# Delete webhook +Uploadcare::Webhook.delete('https://example.com/webhook/uploadcare') +``` + +### Verify Webhook Signatures +```ruby +# In your webhook endpoint +webhook_body = request.body.read +x_uc_signature = request.headers['X-Uc-Signature'] +signing_secret = 'webhook_secret_key' + +is_valid = Uploadcare::Param::WebhookSignatureVerifier.valid?( + webhook_body: webhook_body, + x_uc_signature_header: x_uc_signature, + signing_secret: signing_secret +) + +if is_valid + # Process webhook + data = JSON.parse(webhook_body) + puts "File uploaded: #{data['data']['uuid']}" +else + # Invalid signature + halt 401, 'Invalid signature' +end +``` + +## Add-Ons + +### AWS Rekognition +```ruby +# Detect labels in image +result = Uploadcare::AddOns.aws_rekognition_detect_labels('FILE_UUID') +request_id = result[:request_id] + +# Check status +status = Uploadcare::AddOns.aws_rekognition_detect_labels_status(request_id) +if status[:status] == 'done' + # Labels are now in file's appdata + file = Uploadcare::File.new(uuid: 'FILE_UUID') + info = file.info(include: 'appdata') + labels = info[:appdata][:aws_rekognition_detect_labels] + + labels[:data][:Labels].each do |label| + puts "#{label[:Name]} - #{label[:Confidence]}%" + end +end + +# Detect moderation labels +result = Uploadcare::AddOns.aws_rekognition_detect_moderation_labels('FILE_UUID') +status = Uploadcare::AddOns.aws_rekognition_detect_moderation_labels_status(result[:request_id]) +``` + +### Remove Background +```ruby +# Remove background from image +result = Uploadcare::AddOns.remove_bg( + 'FILE_UUID', + crop: true, # crop to object + type_level: '2', # accuracy level + type: 'person', # object type + scale: '100%', # output scale + position: 'center' # crop position +) + +# Check status +status = Uploadcare::AddOns.remove_bg_status(result[:request_id]) +if status[:status] == 'done' + puts "Result file: #{status[:result][:file_id]}" +end +``` + +### Virus Scanning +```ruby +# Scan file for viruses +result = Uploadcare::AddOns.uc_clamav_virus_scan( + 'FILE_UUID', + purge_infected: true # auto-delete if infected +) + +# Check status +status = Uploadcare::AddOns.uc_clamav_virus_scan_status(result[:request_id]) +if status[:status] == 'done' + file = Uploadcare::File.new(uuid: 'FILE_UUID') + info = file.info(include: 'appdata') + scan_result = info[:appdata][:uc_clamav_virus_scan] + + if scan_result[:data][:infected] + puts "File infected with: #{scan_result[:data][:infected_with]}" + else + puts "File is clean" + end +end +``` + +## Conversions + +### Document Conversion +```ruby +# Check supported formats +info = Uploadcare::DocumentConverter.info('DOCUMENT_UUID') +puts "Current format: #{info[:format][:name]}" +puts "Can convert to: #{info[:format][:conversion_formats].map { |f| f[:name] }.join(', ')}" + +# Convert document +result = Uploadcare::DocumentConverter.convert( + [ + { + uuid: 'DOCUMENT_UUID', + format: 'pdf', + page: 1 # for image output formats + } + ], + store: true +) + +# Check conversion status +token = result[:result].first[:token] +status = Uploadcare::DocumentConverter.status(token) + +if status[:status] == 'finished' + puts "Converted file: #{status[:result][:uuid]}" +elsif status[:status] == 'failed' + puts "Conversion failed: #{status[:error]}" +end + +# Or use File instance method +file = Uploadcare::File.new(uuid: 'DOCUMENT_UUID') +converted = file.convert_document({ format: 'png', page: 1 }, store: true) +``` + +### Video Conversion +```ruby +# Convert video with various options +result = Uploadcare::VideoConverter.convert( + [ + { + uuid: 'VIDEO_UUID', + format: 'mp4', + quality: 'best', + size: { + resize_mode: 'change_ratio', + width: '1920', + height: '1080' + }, + cut: { + start_time: '0:0:10.0', + length: '0:1:00.0' + }, + thumbs: { + N: 10, # number of thumbnails + number: 1 # specific thumbnail + } + } + ], + store: true +) + +# Check status +token = result[:result].first[:token] +status = Uploadcare::VideoConverter.status(token) + +if status[:status] == 'finished' + puts "Converted video: #{status[:result][:uuid]}" + puts "Thumbnails: #{status[:result][:thumbnails_group_uuid]}" +end + +# Using File instance +file = Uploadcare::File.new(uuid: 'VIDEO_UUID') +converted = file.convert_video( + { + format: 'webm', + quality: 'lighter', + size: { resize_mode: 'scale_crop', width: '640', height: '480' } + }, + store: true +) +``` + +## Secure Delivery + +### Generate Authenticated URLs +```ruby +# Configure Akamai generator +generator = Uploadcare::SignedUrlGenerators::AkamaiGenerator.new( + cdn_host: 'cdn.example.com', + secret_key: 'your_akamai_secret', + ttl: 3600, # 1 hour + algorithm: 'sha256' +) + +# Generate basic authenticated URL +uuid = 'a7d5645e-5cd7-4046-819f-a6a2933bafe3' +secure_url = generator.generate_url(uuid) +puts secure_url +# => https://cdn.example.com/a7d5645e-5cd7-4046-819f-a6a2933bafe3/?token=exp=... + +# Generate with custom ACL +secure_url = generator.generate_url(uuid, '/files/*') + +# Generate wildcard URL +secure_url = generator.generate_url(uuid, wildcard: true) +``` + +## Error Handling + +### Handle API Errors +```ruby +begin + file = Uploadcare::File.new(uuid: 'non-existent-uuid') + file.store +rescue Uploadcare::Exception::RequestError => e + puts "Request failed: #{e.message}" + puts "Error code: #{e.error_code}" if e.respond_to?(:error_code) +rescue Uploadcare::Exception::AuthError => e + puts "Authentication failed: #{e.message}" +rescue Uploadcare::Exception::ThrottleError => e + puts "Rate limited. Retry after: #{e.retry_after} seconds" +rescue Uploadcare::Exception::RetryError => e + puts "Max retries exceeded: #{e.message}" +end +``` + +### Handle Upload Errors +```ruby +begin + file = File.open('large_file.bin') + uploaded = Uploadcare::Uploader.upload(file, store: true) +rescue Uploadcare::Exception::ConversionError => e + puts "Conversion failed: #{e.message}" +rescue StandardError => e + puts "Upload failed: #{e.message}" +end +``` + +### Validation and Safe Operations +```ruby +# Validate webhook signature +begin + is_valid = Uploadcare::Param::WebhookSignatureVerifier.valid?( + webhook_body: body, + x_uc_signature_header: signature, + signing_secret: secret + ) +rescue => e + puts "Validation error: #{e.message}" + is_valid = false +end + +# Safe batch operations +result = Uploadcare::File.batch_store(uuids) +if result.success? + puts "All files stored successfully" +else + result.problems.each do |uuid, error| + puts "#{uuid}: #{error}" + end +end +``` + +## Advanced Usage + +### Custom Configuration Per Request +```ruby +# Create client with custom config +custom_client = Uploadcare.client( + public_key: 'different_key', + secret_key: 'different_secret', + max_request_tries: 10 +) + +# Use custom client for operations +files = custom_client.list_files(limit: 5) +``` + +### Working with Rails +```ruby +# In config/initializers/uploadcare.rb +Uploadcare.configure do |config| + config.public_key = Rails.application.credentials.uploadcare[:public_key] + config.secret_key = Rails.application.credentials.uploadcare[:secret_key] +end + +# In your model +class Document < ApplicationRecord + after_create :upload_to_uploadcare + + private + + def upload_to_uploadcare + return unless file.attached? + + uploaded = Uploadcare::Uploader.upload( + file.download, + store: true, + metadata: { document_id: id } + ) + + update(uploadcare_uuid: uploaded.uuid) + end +end + +# In your controller +class DocumentsController < ApplicationController + def show + @document = Document.find(params[:id]) + @file = Uploadcare::File.new(uuid: @document.uploadcare_uuid) + @file_info = @file.info + end +end +``` + +### Using with Background Jobs +```ruby +# app/jobs/upload_job.rb +class UploadJob < ApplicationJob + queue_as :default + + def perform(file_path, metadata = {}) + file = File.open(file_path) + uploaded = Uploadcare::Uploader.upload(file, store: true, metadata: metadata) + + # Process uploaded file + ProcessFileJob.perform_later(uploaded.uuid) + ensure + file&.close + end +end + +# app/jobs/process_file_job.rb +class ProcessFileJob < ApplicationJob + def perform(uuid) + file = Uploadcare::File.new(uuid: uuid) + + # Apply add-ons + Uploadcare::AddOns.aws_rekognition_detect_labels(uuid) + + # Convert if needed + if file.info[:mime_type].start_with?('video/') + file.convert_video({ format: 'mp4', quality: 'normal' }, store: true) + end + end +end +``` + +## Testing + +### Mocking Uploadcare in Tests +```ruby +# spec/support/uploadcare_helpers.rb +module UploadcareHelpers + def stub_uploadcare_upload + allow(Uploadcare::Uploader).to receive(:upload).and_return( + double( + uuid: 'test-uuid-1234', + original_file_url: 'https://ucarecdn.com/test-uuid-1234/test.jpg' + ) + ) + end + + def stub_uploadcare_file_info + allow_any_instance_of(Uploadcare::File).to receive(:info).and_return( + { + uuid: 'test-uuid-1234', + original_filename: 'test.jpg', + size: 1024, + mime_type: 'image/jpeg' + } + ) + end +end + +# In your specs +RSpec.describe DocumentsController, type: :controller do + include UploadcareHelpers + + before do + stub_uploadcare_upload + stub_uploadcare_file_info + end + + it 'uploads file to Uploadcare' do + post :create, params: { file: fixture_file_upload('test.jpg') } + expect(response).to be_successful + end +end +``` + +## Debugging + +### Enable Request Logging +```ruby +Uploadcare.configure do |config| + config.logger = Logger.new(STDOUT) + config.log_level = :debug +end + +# Now all requests/responses will be logged +file = Uploadcare::File.new(uuid: 'test') +file.info # Will log request details +``` + +### Inspect Response Headers +```ruby +# Most operations return response objects with headers +result = Uploadcare::File.list +puts result.response.headers['X-RateLimit-Remaining'] +``` + +## Performance Tips + +1. **Use batch operations** when working with multiple files +2. **Enable caching** for file info requests in production +3. **Use multipart upload** for files larger than 100MB +4. **Implement retry logic** for network errors +5. **Use webhooks** instead of polling for async operations +6. **Store file UUIDs** in your database to avoid repeated API calls + +## Migration from v2.x to v3.x + +If you're upgrading from v2.x, here are the main changes: + +```ruby +# Old (v2.x) +@api = Uploadcare::Api.new +@api.upload(file) +@api.file('uuid') + +# New (v3.x) - Using resources directly +Uploadcare::Uploader.upload(file) +Uploadcare::File.new(uuid: 'uuid').info + +# Or using the new client +client = Uploadcare.client +client.upload_file(file) +client.file_info(uuid: 'uuid') +``` + +## Support + +For more information: +- [API Documentation](https://uploadcare.com/api-refs/) +- [Ruby SDK GitHub](https://github.com/uploadcare/uploadcare-ruby) +- [Support](https://uploadcare.com/support/) \ No newline at end of file diff --git a/Gemfile b/Gemfile index d3f8c60d..b1d19692 100644 --- a/Gemfile +++ b/Gemfile @@ -6,8 +6,11 @@ gem 'byebug' gem 'rake' gem 'rspec' gem 'rubocop' +gem 'simplecov', require: false +gem 'simplecov-lcov', require: false gem 'vcr' gem 'webmock' # Specify your gem's dependencies in uploadcare-ruby.gemspec gemspec +gem 'base64' diff --git a/README.md b/README.md index a4c299e9..b5c5396d 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,6 @@ And then execute: $ bundle -If you use `api_struct` gem in your project, replace it with `uploadcare-api_struct`: -```ruby -gem 'uploadcare-api_struct' -``` -and run `bundle install` - If already not, create your project in [Uploadcare dashboard](https://app.uploadcare.com/?utm_source=github&utm_medium=referral&utm_campaign=uploadcare-ruby) and copy its [API keys](https://app.uploadcare.com/projects/-/api-keys/) from there. @@ -87,6 +81,59 @@ and [Upload](https://uploadcare.com/api-refs/upload-api/) and [REST](https://upl You can also find an example project [here](https://github.com/uploadcare/uploadcare-rails-example). +### New Architecture (v3.5+) + +The gem now provides a cleaner, more intuitive interface for interacting with Uploadcare APIs: + +```ruby +# Using the new Client interface (recommended) +client = Uploadcare.client( + public_key: 'your_public_key', + secret_key: 'your_secret_key' +) + +# Upload a file +file = client.upload_file(File.open("image.jpg")) +puts file.uuid + +# Manage files +file_info = client.file_info(uuid: "FILE_UUID") +stored_file = client.store_file(uuid: "FILE_UUID") +deleted_file = client.delete_file(uuid: "FILE_UUID") + +# Batch operations +result = client.batch_store_files(["uuid1", "uuid2", "uuid3"]) +result = client.batch_delete_files(["uuid1", "uuid2", "uuid3"]) + +# List files with pagination +files = client.list_files(limit: 10, stored: true) +files.each { |f| puts f.original_filename } + +# Work with groups +group = client.create_group(["uuid1", "uuid2"]) +group_info = client.group_info(uuid: "GROUP_UUID") + +# Webhooks management +webhook = client.create_webhook( + target_url: "https://example.com/webhook", + event: "file.uploaded" +) +webhooks = client.list_webhooks + +# Add-ons +client.aws_rekognition_detect_labels("FILE_UUID") +client.remove_bg("FILE_UUID", crop: true) +client.uc_clamav_virus_scan("FILE_UUID", purge_infected: true) + +# Conversion +client.convert_document([{ uuid: "FILE_UUID", format: "pdf" }]) +client.convert_video([{ uuid: "FILE_UUID", format: "mp4", quality: "best" }]) +``` + +### Legacy API Interface (deprecated, but still supported) + +The old `Uploadcare::Api` interface is still available for backward compatibility: + ### Uploading files #### Uploading and storing a single file @@ -115,11 +162,11 @@ Your might then want to store or delete the uploaded file. ```ruby # that's how you store a file, if you have uploaded the file using store: false and changed your mind later @uc_file.store -# => # # # #nil, "datetime_stored"=>"2018-11-26T12:49:10.477888Z", @@ -313,19 +360,86 @@ File entity contains its metadata. It also supports `include` param to include a } } -@file.local_copy # copy file to local storage +``` +#### Storing Files + +# Store a single file +``` ruby +file = Uploadcare::File.new(uuid: "FILE_UUID") +stored_file = file.store + +puts stored_file.datetime_stored +# => "2024-11-05T09:13:40.543471Z" +``` + +# Batch store files using their UUIDs +``` ruby +uuids = ['uuid1', 'uuid2', 'uuid3'] +batch_result = Uploadcare::File.batch_store(uuids) +``` -@file.remote_copy # copy file to remote storage +# Check the status of the operation +``` ruby +puts batch_result.status # => "success" +``` -@file.store # stores file, returns updated metadata +# Access successfully stored files +``` ruby +batch_result.result.each do |file| + puts file.uuid +end +``` -@file.delete #deletes file. Returns updated metadata +# Handle files that encountered issues +``` ruby +unless batch_result.problems.empty? + batch_result.problems.each do |uuid, error| + puts "Failed to store file #{uuid}: #{error}" + end +end ``` -The File object is also can be converted if it is a document or a video file. Imagine, you have a document file: +#### Deleting Files +# Delete a single file ```ruby -@file = Uploadcare::File.file("FILE_UUID") +file = Uploadcare::File.new(uuid: "FILE_UUID") +deleted_file = file.delete +puts deleted_file.datetime_removed +# => "2024-11-05T09:13:40.543471Z" +``` + +# Batch delete multiple files +```ruby +uuids = ['FILE_UUID_1', 'FILE_UUID_2'] +result = Uploadcare::File.batch_delete(uuids) +puts result.result +``` + +#### Copying Files + +# Copy a file to local storage +```ruby +source = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +file = Uploadcare::File.local_copy(source, store: true) + +puts file.uuid +# => "new-uuid-of-the-copied-file" +``` + +# Copy a file to remote storage +```ruby +source_object = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +target = 'custom_storage_connected_to_the_project' +file = Uploadcare::File.remote_copy(source_object, target, make_public: true) + +puts file +# => "https://my-storage.example.com/path/to/copied-file" +``` +The File object also can be converted if it is a document or a video file. Imagine, you have a document file: + +```ruby +@file = Uploadcare::File.new(uuid: "FILE_UUID") ``` To convert it to an another file, just do: @@ -366,25 +480,20 @@ Metadata of deleted files is stored permanently. #### FileList -`Uploadcare::FileList` represents the whole collection of files (or it's -subset) and provides a way to iterate through it, making pagination transparent. -FileList objects can be created using `Uploadcare::FileList.file_list` method. +`Uploadcare::File.list` retrieves a collection of files from Uploadcare, supporting optional filtering and pagination. It provides methods to iterate through the collection and access associated file objects seamlessly. ```ruby -@list = Uploadcare::FileList.file_list -# Returns instance of Uploadcare::Entity::FileList - -# load last page of files -@files = @list.files -# load all files -@all_files = @list.load +# Retrieve a list of files +options = { + limit: 10, # Controls the number of files returned (default: 100) + stored: true, # Only include stored files (optional) + removed: false, # Exclude removed files (optional) + ordering: '-datetime_uploaded', # Order by latest uploaded files first + from: '2022-01-01T00:00:00' # Start from this point in the collection +} + +@file_list = Uploadcare::File.list(options) +# => Returns an instance of PaginatedCollection containing Uploadcare::File objects ``` This method accepts some options to control which files should be fetched and @@ -407,7 +516,7 @@ options = { ordering: "-datetime_uploaded", from: "2017-01-01T00:00:00", } -@list = @api.file_list(options) +@list = Uploadcare::File.list(options) ``` To simply get all associated objects: @@ -417,9 +526,9 @@ To simply get all associated objects: #### Pagination -Initially, `FileList` is a paginated collection. It can be navigated using following methods: +Initially, `File.list` returns a paginated collection. It can be navigated using following methods: ```ruby - @file_list = Uploadcare::FileList.file_list + @file_list = Uploadcare::File.list # Let's assume there are 250 files in cloud. By default, UC loads 100 files. To get next 100 files, do: @next_page = @file_list.next_page # To get previous page: @@ -469,18 +578,20 @@ That's a requirement of our API. Uploadcare::Group.store(group.id) # get a file group by its ID. -Uploadcare::Group.rest_info(group.id) +@group = Uploadcare::Group.new(uuid: "Group UUID") +@group.info("Group UUID") # group can be deleted by group ID. -Uploadcare::Group.delete(group.id) +@group = Uploadcare::Group.new(uuid: "Group UUID") +@group.delete("Group UUID") # Note: This operation only removes the group object itself. All the files that were part of the group are left as is. ``` #### GroupList -`GroupList` is a list of `Group` +`Group.list` returns a list of `Group` ```ruby -@group_list = Uploadcare::GroupList.list +@group_list = Uploadcare::Group.list # To get an array of groups: @groups = @group_list.all ``` @@ -558,10 +669,10 @@ An `Add-On` is an application implemented by Uploadcare that accepts uploaded fi ```ruby # Execute AWS Rekognition Add-On for a given target to detect labels in an image. # Note: Detected labels are stored in the file's appdata. -Uploadcare::Addons.ws_rekognition_detect_labels('FILE_UUID') +Uploadcare::AddOns.aws_rekognition_detect_labels('FILE_UUID') # Check the status of AWS Rekognition. -Uploadcare::Addons.ws_rekognition_detect_labels_status('RETURNED_ID_FROM_WS_REKOGNITION_DETECT_LABELS') +Uploadcare::AddOns.aws_rekognition_detect_labels_status('RETURNED_ID_FROM_WS_REKOGNITION_DETECT_LABELS') ``` ##### AWS Rekognition Moderation @@ -570,48 +681,48 @@ Uploadcare::Addons.ws_rekognition_detect_labels_status('RETURNED_ID_FROM_WS_REKO # Execute AWS Rekognition Moderation Add-On for a given target to detect moderation labels in an image. # Note: Detected moderation labels are stored in the file's appdata. -Uploadcare::Addons.ws_rekognition_detect_moderation_labels('FILE_UUID') +Uploadcare::AddOns.aws_rekognition_detect_moderation_labels('FILE_UUID') # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. -Uploadcare::Addons.ws_rekognition_detect_moderation_labels_status('RETURNED_ID_FROM_WS_REKOGNITION_DETECT_MODERATION_LABELS') +Uploadcare::AddOns.aws_rekognition_detect_moderation_labels_status('RETURNED_ID_FROM_WS_REKOGNITION_DETECT_MODERATION_LABELS') ``` ##### ClamAV ```ruby # ClamAV virus checking Add-On for a given target. -Uploadcare::Addons.uc_clamav_virus_scan('FILE_UUID') +Uploadcare::AddOns.uc_clamav_virus_scan('FILE_UUID') # Check and purge infected file. -Uploadcare::Addons.uc_clamav_virus_scan('FILE_UUID', purge_infected: true ) +Uploadcare::AddOns.uc_clamav_virus_scan('FILE_UUID', purge_infected: true ) # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. -Uploadcare::Addons.uc_clamav_virus_scan_status('RETURNED_ID_FROM_UC_CLAMAV_VIRUS_SCAN') +Uploadcare::AddOns.uc_clamav_virus_scan_status('RETURNED_ID_FROM_UC_CLAMAV_VIRUS_SCAN') ``` ##### Remove.bg ```ruby # Execute remove.bg background image removal Add-On for a given target. -Uploadcare::Addons.remove_bg('FILE_UUID') +Uploadcare::AddOns.remove_bg('FILE_UUID') # You can pass optional parameters. # See the full list of parameters here: https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/removeBgExecute -Uploadcare::Addons.remove_bg('FILE_UUID', crop: true, type_level: '2') +Uploadcare::AddOns.remove_bg('FILE_UUID', crop: true, type_level: '2') # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. -Uploadcare::Addons.remove_bg_status('RETURNED_ID_FROM_REMOVE_BG') +Uploadcare::AddOns.remove_bg_status('RETURNED_ID_FROM_REMOVE_BG') ``` #### Project -`Project` provides basic info about the connected Uploadcare project. That +`show` provides basic info about the connected Uploadcare project. That object is also an Hashie::Mash, so every methods out of [these](https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/projectInfo) will work. ```ruby -@project = Uploadcare::Project.project -# => # +@project = Uploadcare::Project.show +# => # @project.name # => "demo" diff --git a/Rakefile b/Rakefile index 82bb534a..49647511 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,8 @@ require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -task default: :spec +require 'rubocop/rake_task' + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/api_examples/rest_api/delete_files_storage.rb b/api_examples/rest_api/delete_files_storage.rb index e3054757..e71ef8fa 100644 --- a/api_examples/rest_api/delete_files_storage.rb +++ b/api_examples/rest_api/delete_files_storage.rb @@ -1,6 +1,20 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuids = %w[21975c81-7f57-4c7a-aef9-acfe28779f78 cbaf2d73-5169-4b2b-a543-496cf2813dff] -puts Uploadcare::FileList.batch_delete(uuids) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Delete file from storage +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Method 1: Using File resource +file = Uploadcare::File.new(uuid: uuid) +deleted_file = file.delete +puts "File deleted at: #{deleted_file.datetime_removed}" + +# Method 2: Using client interface +client = Uploadcare.client +result = client.delete_file(uuid: uuid) +puts result.inspect diff --git a/api_examples/rest_api/delete_files_uuid_metadata_key.rb b/api_examples/rest_api/delete_files_uuid_metadata_key.rb index 2d1f5d4c..7314d542 100644 --- a/api_examples/rest_api/delete_files_uuid_metadata_key.rb +++ b/api_examples/rest_api/delete_files_uuid_metadata_key.rb @@ -1,5 +1,15 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -puts Uploadcare::FileMetadata.delete('1bac376c-aa7e-4356-861b-dd2657b5bfd2', 'pet') +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Delete specific metadata key from a file +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +key = 'custom_key' + +# Delete metadata key +result = Uploadcare::FileMetadata.delete(uuid, key) +puts "Metadata key '#{key}' deleted from file #{uuid}" diff --git a/api_examples/rest_api/delete_files_uuid_storage.rb b/api_examples/rest_api/delete_files_uuid_storage.rb index 8837391b..8420b355 100644 --- a/api_examples/rest_api/delete_files_uuid_storage.rb +++ b/api_examples/rest_api/delete_files_uuid_storage.rb @@ -1,5 +1,16 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -puts Uploadcare::File.delete('1bac376c-aa7e-4356-861b-dd2657b5bfd2') +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Remove file from storage (but keep metadata) +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Using File resource +file = Uploadcare::File.new(uuid: uuid) +result = file.delete +puts "File removed from storage: #{result.uuid}" +puts "Removal time: #{result.datetime_removed}" diff --git a/api_examples/rest_api/delete_groups_uuid.rb b/api_examples/rest_api/delete_groups_uuid.rb index 203527b7..aa06f7e8 100644 --- a/api_examples/rest_api/delete_groups_uuid.rb +++ b/api_examples/rest_api/delete_groups_uuid.rb @@ -1,5 +1,17 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -puts Uploadcare::Group.delete('c5bec8c7-d4b6-4921-9e55-6edb027546bc~1') +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Delete a file group +group_uuid = 'GROUP_UUID~2' + +# Method 1: Using Group resource +group = Uploadcare::Group.new(uuid: group_uuid) +group.delete +puts "Group deleted: #{group_uuid}" + +# Note: Files in the group are not deleted, only the group itself diff --git a/api_examples/rest_api/delete_webhooks_unsubscribe.rb b/api_examples/rest_api/delete_webhooks_unsubscribe.rb index c1c0f8da..6515aa61 100644 --- a/api_examples/rest_api/delete_webhooks_unsubscribe.rb +++ b/api_examples/rest_api/delete_webhooks_unsubscribe.rb @@ -1,5 +1,14 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -puts Uploadcare::Webhook.delete('https://yourwebhook.com') +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Delete/unsubscribe from a webhook +target_url = 'https://example.com/webhook/uploadcare' + +# Delete webhook by target URL +Uploadcare::Webhook.delete(target_url) +puts "Webhook unsubscribed: #{target_url}" diff --git a/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb b/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb index 0d43b9ae..8974f01a 100644 --- a/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb +++ b/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb @@ -1,7 +1,22 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -request_id = 'd1fb31c6-ed34-4e21-bdc3-4f1485f58e21' -result = Uploadcare::Addons.ws_rekognition_detect_labels_status(request_id) -puts result.status +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Check AWS Rekognition label detection status +request_id = 'REQUEST_ID_FROM_EXECUTE' + +# Check status +status = Uploadcare::AddOns.aws_rekognition_detect_labels_status(request_id) + +if status[:status] == 'done' + puts "Labels detected successfully" + # Labels are now available in file's appdata +elsif status[:status] == 'error' + puts "Detection failed: #{status[:error]}" +else + puts "Detection in progress..." +end diff --git a/api_examples/rest_api/get_convert_document_status_token.rb b/api_examples/rest_api/get_convert_document_status_token.rb index 77b7fa68..953e87df 100644 --- a/api_examples/rest_api/get_convert_document_status_token.rb +++ b/api_examples/rest_api/get_convert_document_status_token.rb @@ -1,6 +1,23 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -token = 32_921_143 -puts Uploadcare::DocumentConverter.status(token) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Check document conversion status +token = 123456 # Token from conversion request + +# Check status +status = Uploadcare::DocumentConverter.status(token) + +case status[:status] +when 'finished' + puts "Conversion completed" + puts "Result UUID: #{status[:result][:uuid]}" +when 'processing' + puts "Conversion in progress..." +when 'failed' + puts "Conversion failed: #{status[:error]}" +end diff --git a/api_examples/rest_api/get_convert_video_status_token.rb b/api_examples/rest_api/get_convert_video_status_token.rb index c4295d12..c58099a2 100644 --- a/api_examples/rest_api/get_convert_video_status_token.rb +++ b/api_examples/rest_api/get_convert_video_status_token.rb @@ -1,6 +1,24 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -token = 1_201_016_744 -puts Uploadcare::VideoConverter.status(token) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Check video conversion status +token = 123456 # Token from conversion request + +# Check status +status = Uploadcare::VideoConverter.status(token) + +case status[:status] +when 'finished' + puts "Video conversion completed" + puts "Result UUID: #{status[:result][:uuid]}" + puts "Thumbnails: #{status[:result][:thumbnails_group_uuid]}" +when 'processing' + puts "Conversion in progress..." +when 'failed' + puts "Conversion failed: #{status[:error]}" +end diff --git a/api_examples/rest_api/get_files.rb b/api_examples/rest_api/get_files.rb index e83aa207..bf9eea56 100644 --- a/api_examples/rest_api/get_files.rb +++ b/api_examples/rest_api/get_files.rb @@ -1,6 +1,36 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -list = Uploadcare::FileList.file_list(stored: true, removed: false, limit: 100) -list.each { |file| puts file.inspect } +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Method 1: Using the new query interface (Rails-style) +files = Uploadcare::File + .where(stored: true, removed: false) + .limit(100) + .order(:datetime_uploaded, :desc) + +files.each do |file| + puts "UUID: #{file.uuid}" + puts "Filename: #{file.original_filename}" + puts "Size: #{file.size} bytes" + puts "URL: #{file.original_file_url}" + puts "---" +end + +# Method 2: Using the traditional list method +file_list = Uploadcare::File.list( + stored: true, + removed: false, + limit: 100, + ordering: '-datetime_uploaded' +) + +file_list.each { |file| puts file.inspect } + +# Method 3: Using the new client interface +client = Uploadcare.client +files = client.list_files(stored: true, removed: false, limit: 100) +files.each { |file| puts file.inspect } diff --git a/api_examples/rest_api/get_files_uuid.rb b/api_examples/rest_api/get_files_uuid.rb index 900fde2a..fd735ade 100644 --- a/api_examples/rest_api/get_files_uuid.rb +++ b/api_examples/rest_api/get_files_uuid.rb @@ -1,6 +1,31 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' + +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -puts Uploadcare::File.info(uuid).inspect + +# Method 1: Using File resource directly +file = Uploadcare::File.new(uuid: uuid) +info = file.info(include: 'appdata,metadata') + +puts "File Information:" +puts "UUID: #{info[:uuid]}" +puts "Filename: #{info[:original_filename]}" +puts "Size: #{info[:size]} bytes" +puts "MIME type: #{info[:mime_type]}" +puts "Stored: #{info[:datetime_stored].present?}" +puts "URL: #{info[:original_file_url]}" +puts "Metadata: #{info[:metadata]}" + +# Method 2: Using client interface +client = Uploadcare.client +file_info = client.file_info(uuid: uuid) +puts file_info.inspect + +# Method 3: With caching support (if cache configured) +file = Uploadcare::File.cached_find(uuid) if Uploadcare::File.respond_to?(:cached_find) +puts file.info.inspect if file diff --git a/api_examples/rest_api/get_files_uuid_metadata.rb b/api_examples/rest_api/get_files_uuid_metadata.rb index 7701512e..302938e3 100644 --- a/api_examples/rest_api/get_files_uuid_metadata.rb +++ b/api_examples/rest_api/get_files_uuid_metadata.rb @@ -1,6 +1,24 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Get all metadata for a file uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -puts Uploadcare::FileMetadata.show(uuid, 'pet') + +# Get all metadata keys and values +metadata = Uploadcare::FileMetadata.index(uuid) + +puts "File metadata for #{uuid}:" +metadata.each do |key, value| + puts " #{key}: #{value}" +end + +# Alternative: Get metadata through file info +file = Uploadcare::File.new(uuid: uuid) +info = file.info(include: 'metadata') +puts "\nMetadata from file info:" +puts info[:metadata] \ No newline at end of file diff --git a/api_examples/rest_api/get_groups.rb b/api_examples/rest_api/get_groups.rb index c2e61e96..5a630f4b 100644 --- a/api_examples/rest_api/get_groups.rb +++ b/api_examples/rest_api/get_groups.rb @@ -1,6 +1,24 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -groups = Uploadcare::GroupList.list(limit: 10) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# List file groups + +# Method 1: Using Group.list +groups = Uploadcare::Group.list + +groups.each do |group| + puts "Group ID: #{group.id}" + puts "Files count: #{group.files_count}" + puts "Created: #{group.datetime_created}" + puts "---" +end + +# Method 2: Using client interface +client = Uploadcare.client +groups = client.list_groups groups.each { |group| puts group.inspect } diff --git a/api_examples/rest_api/get_groups_uuid.rb b/api_examples/rest_api/get_groups_uuid.rb index e5e91ddd..f04cbf42 100644 --- a/api_examples/rest_api/get_groups_uuid.rb +++ b/api_examples/rest_api/get_groups_uuid.rb @@ -1,6 +1,26 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuid = 'c5bec8c7-d4b6-4921-9e55-6edb027546bc~1' -puts Uploadcare::Group.info(uuid).inspect +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Get group information +group_uuid = 'GROUP_UUID~2' + +# Method 1: Using Group resource +group = Uploadcare::Group.new(uuid: group_uuid) +info = group.info + +puts "Group ID: #{info[:id]}" +puts "Files count: #{info[:files_count]}" +puts "Files:" +info[:files].each do |file| + puts " - #{file[:uuid]} (#{file[:original_filename]})" +end + +# Method 2: Using client interface +client = Uploadcare.client +group_info = client.group_info(uuid: group_uuid) +puts group_info.inspect diff --git a/api_examples/rest_api/get_project.rb b/api_examples/rest_api/get_project.rb index c6c0413c..9e503769 100644 --- a/api_examples/rest_api/get_project.rb +++ b/api_examples/rest_api/get_project.rb @@ -1,6 +1,22 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -project_info = Uploadcare::Project.show +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Get project information + +# Method 1: Using Project resource +project = Uploadcare::Project.show + +puts "Project name: #{project.name}" +puts "Public key: #{project.pub_key}" +puts "Autostore enabled: #{project.autostore_enabled}" +puts "Collaborators: #{project.collaborators.count}" + +# Method 2: Using client interface +client = Uploadcare.client +project_info = client.project_info puts project_info.inspect diff --git a/api_examples/rest_api/get_webhooks.rb b/api_examples/rest_api/get_webhooks.rb index 01dd5d33..1e5a5321 100644 --- a/api_examples/rest_api/get_webhooks.rb +++ b/api_examples/rest_api/get_webhooks.rb @@ -1,6 +1,25 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# List all webhooks + +# Method 1: Using Webhook.list webhooks = Uploadcare::Webhook.list + +webhooks.each do |webhook| + puts "ID: #{webhook.id}" + puts "Target URL: #{webhook.target_url}" + puts "Event: #{webhook.event}" + puts "Active: #{webhook.is_active}" + puts "---" +end + +# Method 2: Using client interface +client = Uploadcare.client +webhooks = client.list_webhooks webhooks.each { |webhook| puts webhook.inspect } diff --git a/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb b/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb index 0df40b2f..a3b2dcf3 100644 --- a/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb +++ b/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb @@ -1,6 +1,19 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -Uploadcare::Addons.ws_rekognition_detect_labels(uuid) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Execute AWS Rekognition label detection +uuid = 'FILE_UUID' + +# Execute detection +result = Uploadcare::AddOns.aws_rekognition_detect_labels(uuid) +request_id = result[:request_id] + +puts "Detection started with request ID: #{request_id}" +puts "Check status with: Uploadcare::AddOns.aws_rekognition_detect_labels_status('#{request_id}')" + +# Results will be available in file's appdata when complete diff --git a/api_examples/rest_api/post_addons_remove_bg_execute.rb b/api_examples/rest_api/post_addons_remove_bg_execute.rb index a78d7081..e64e2251 100644 --- a/api_examples/rest_api/post_addons_remove_bg_execute.rb +++ b/api_examples/rest_api/post_addons_remove_bg_execute.rb @@ -1,6 +1,29 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -Uploadcare::Addons.remove_bg(uuid, crop: true) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Remove background from image +uuid = 'FILE_UUID' + +# Execute background removal with options +result = Uploadcare::AddOns.remove_bg( + uuid, + crop: true, # Crop to object + type_level: '2', # Accuracy level (1 or 2) + type: 'person', # Object type: person, product, car + scale: '100%', # Output scale + position: 'center' # Crop position if cropping +) + +request_id = result[:request_id] +puts "Background removal started with request ID: #{request_id}" + +# Check status +status = Uploadcare::AddOns.remove_bg_status(request_id) +if status[:status] == 'done' + puts "Result file UUID: #{status[:result][:file_id]}" +end diff --git a/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb b/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb index 340e660f..15f8a4cd 100644 --- a/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb +++ b/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb @@ -1,6 +1,34 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -Uploadcare::Addons.uc_clamav_virus_scan(uuid, purge_infected: true) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Scan file for viruses +uuid = 'FILE_UUID' + +# Execute virus scan with auto-purge if infected +result = Uploadcare::AddOns.uc_clamav_virus_scan( + uuid, + purge_infected: true # Automatically delete if infected +) + +request_id = result[:request_id] +puts "Virus scan started with request ID: #{request_id}" + +# Check status +status = Uploadcare::AddOns.uc_clamav_virus_scan_status(request_id) +if status[:status] == 'done' + # Check file's appdata for scan results + file = Uploadcare::File.new(uuid: uuid) + info = file.info(include: 'appdata') + scan_data = info[:appdata][:uc_clamav_virus_scan][:data] + + if scan_data[:infected] + puts "File infected with: #{scan_data[:infected_with]}" + else + puts "File is clean" + end +end diff --git a/api_examples/rest_api/post_convert_document.rb b/api_examples/rest_api/post_convert_document.rb index cc710529..d41263f5 100644 --- a/api_examples/rest_api/post_convert_document.rb +++ b/api_examples/rest_api/post_convert_document.rb @@ -1,9 +1,36 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' - -document_params = { uuid: '1bac376c-aa7e-4356-861b-dd2657b5bfd2', format: :pdf } -options = { store: '1' } -# for multipage conversion -# options = { store: '1', save_in_group: '1' } -Uploadcare::DocumentConverter.convert(document_params, options) + +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Convert document to different format +uuid = 'DOCUMENT_UUID' + +# Check supported formats first +info = Uploadcare::DocumentConverter.info(uuid) +puts "Current format: #{info[:format][:name]}" +puts "Can convert to: #{info[:format][:conversion_formats].map { |f| f[:name] }.join(', ')}" + +# Convert document +result = Uploadcare::DocumentConverter.convert( + [ + { + uuid: uuid, + format: 'pdf', # Target format + page: 1 # For image outputs, specific page number + } + ], + store: true # Store the result +) + +token = result[:result].first[:token] +puts "Conversion started with token: #{token}" + +# Check status +status = Uploadcare::DocumentConverter.status(token) +if status[:status] == 'finished' + puts "Converted file UUID: #{status[:result][:uuid]}" +end diff --git a/api_examples/rest_api/post_convert_video.rb b/api_examples/rest_api/post_convert_video.rb index 2414d3d3..e2557491 100644 --- a/api_examples/rest_api/post_convert_video.rb +++ b/api_examples/rest_api/post_convert_video.rb @@ -1,11 +1,50 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' - -video_params = { - uuid: '1bac376c-aa7e-4356-861b-dd2657b5bfd2', - format: :mp4, - quality: :lighter -} -options = { store: true } -Uploadcare::VideoConverter.convert(video_params, options) + +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Convert video with various options +uuid = 'VIDEO_UUID' + +# Convert video +result = Uploadcare::VideoConverter.convert( + [ + { + uuid: uuid, + format: 'mp4', # Output format: mp4, webm, ogg + quality: 'normal', # Quality: normal, better, best, lighter, lightest + size: { + resize_mode: 'change_ratio', # preserve_ratio, change_ratio, scale_crop, add_padding + width: '1280', + height: '720' + }, + cut: { + start_time: '0:0:0.0', # Start time + length: '0:1:0.0' # Duration (or 'end') + }, + thumbs: { + N: 10, # Number of thumbnails + number: 1 # Specific thumbnail index + } + } + ], + store: true +) + +token = result[:result].first[:token] +uuid_result = result[:result].first[:uuid] +thumbnails = result[:result].first[:thumbnails_group_uuid] + +puts "Conversion started" +puts "Token: #{token}" +puts "Result UUID: #{uuid_result}" +puts "Thumbnails group: #{thumbnails}" + +# Check status +status = Uploadcare::VideoConverter.status(token) +if status[:status] == 'finished' + puts "Video conversion completed!" +end diff --git a/api_examples/rest_api/post_files_local_copy.rb b/api_examples/rest_api/post_files_local_copy.rb index 4860110a..f784356f 100644 --- a/api_examples/rest_api/post_files_local_copy.rb +++ b/api_examples/rest_api/post_files_local_copy.rb @@ -1,7 +1,20 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -source = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -copied_file = Uploadcare::File.local_copy(source, store: true) -puts copied_file.uuid +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Create a local copy of a file +source_uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Create local copy +copied_file = Uploadcare::File.local_copy( + source_uuid, + store: true # Store the copy immediately +) + +puts "Original UUID: #{source_uuid}" +puts "Copy UUID: #{copied_file.uuid}" +puts "Copy URL: #{copied_file.original_file_url}" diff --git a/api_examples/rest_api/post_files_remote_copy.rb b/api_examples/rest_api/post_files_remote_copy.rb index f60c8beb..efcb749b 100644 --- a/api_examples/rest_api/post_files_remote_copy.rb +++ b/api_examples/rest_api/post_files_remote_copy.rb @@ -1,8 +1,21 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -source_object = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -target = 'custom_storage_connected_to_the_project' -copied_file_url = Uploadcare::File.remote_copy(source_object, target, make_public: true) -puts copied_file_url +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Copy file to remote storage +source_uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +target_storage = 'my-s3-bucket' # Preconfigured storage name + +# Copy to remote storage +result = Uploadcare::File.remote_copy( + source_uuid, + target_storage, + make_public: true, # Make publicly accessible + pattern: 'uploads/${year}/${month}/${filename}' # Optional path pattern +) + +puts "File copied to: #{result}" diff --git a/api_examples/rest_api/post_webhooks.rb b/api_examples/rest_api/post_webhooks.rb index 49d63f2c..98861930 100644 --- a/api_examples/rest_api/post_webhooks.rb +++ b/api_examples/rest_api/post_webhooks.rb @@ -1,10 +1,22 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -options = { - target_url: 'https://yourwebhook.com', - event: 'file.uploaded', - is_active: true -} -Uploadcare::Webhook.create(**options) +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Create a new webhook +webhook = Uploadcare::Webhook.create( + target_url: 'https://example.com/webhook/uploadcare', + event: 'file.uploaded', # Events: file.uploaded, file.stored, file.deleted, etc. + is_active: true, + signing_secret: 'your_webhook_secret', # For signature verification + version: '0.7' +) + +puts "Webhook created" +puts "ID: #{webhook.id}" +puts "Target: #{webhook.target_url}" +puts "Event: #{webhook.event}" +puts "Active: #{webhook.is_active}" diff --git a/api_examples/rest_api/put_files_storage.rb b/api_examples/rest_api/put_files_storage.rb index 6553abac..3327a126 100644 --- a/api_examples/rest_api/put_files_storage.rb +++ b/api_examples/rest_api/put_files_storage.rb @@ -1,9 +1,31 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -uuids = %w[ - b7a301d1-1bd0-473d-8d32-708dd55addc0 - 1bac376c-aa7e-4356-861b-dd2657b5bfd2 +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Batch store multiple files +uuids = [ + '1bac376c-aa7e-4356-861b-dd2657b5bfd2', + 'a4b9db2f-1591-4f4c-8f68-94018924525d' ] -Uploadcare::FileList.batch_store(uuids) + +# Batch store +result = Uploadcare::File.batch_store(uuids) + +if result.status == 'success' + puts "Successfully stored #{result.result.count} files:" + result.result.each do |file| + puts " - #{file.uuid}: stored at #{file.datetime_stored}" + end +end + +# Handle any problems +if result.problems.any? + puts "Problems encountered:" + result.problems.each do |uuid, error| + puts " - #{uuid}: #{error}" + end +end diff --git a/api_examples/rest_api/put_files_uuid_metadata_key.rb b/api_examples/rest_api/put_files_uuid_metadata_key.rb index 48f447f6..9d4c1f19 100644 --- a/api_examples/rest_api/put_files_uuid_metadata_key.rb +++ b/api_examples/rest_api/put_files_uuid_metadata_key.rb @@ -1,8 +1,20 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Update file metadata uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -key = 'pet' -value = 'dog' -Uploadcare::FileMetadata.update(uuid, key, value) +key = 'department' +value = 'marketing' + +# Update metadata +result = Uploadcare::FileMetadata.update(uuid, key, value) +puts "Metadata updated: #{key} = #{value}" + +# Retrieve metadata +metadata_value = Uploadcare::FileMetadata.show(uuid, key) +puts "Current value: #{metadata_value}" diff --git a/api_examples/rest_api/put_files_uuid_storage.rb b/api_examples/rest_api/put_files_uuid_storage.rb index c3343c89..ee25d6dd 100644 --- a/api_examples/rest_api/put_files_uuid_storage.rb +++ b/api_examples/rest_api/put_files_uuid_storage.rb @@ -1,6 +1,20 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Store a single file uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' -Uploadcare::File.store(uuid) + +# Method 1: Using File resource +file = Uploadcare::File.new(uuid: uuid) +stored_file = file.store +puts "File stored at: #{stored_file.datetime_stored}" + +# Method 2: Using client interface +client = Uploadcare.client +result = client.store_file(uuid: uuid) +puts result.inspect diff --git a/api_examples/rest_api/put_webhooks_id.rb b/api_examples/rest_api/put_webhooks_id.rb index b06a6cc3..93b15097 100644 --- a/api_examples/rest_api/put_webhooks_id.rb +++ b/api_examples/rest_api/put_webhooks_id.rb @@ -1,12 +1,31 @@ require 'uploadcare' -Uploadcare.config.public_key = 'YOUR_PUBLIC_KEY' -Uploadcare.config.secret_key = 'YOUR_SECRET_KEY' -webhook_id = 1_473_151 -options = { - target_url: 'https://yourwebhook.com', - event: 'file.uploaded', +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end + +# Update an existing webhook +webhook_id = 123 # Webhook ID from creation or list + +# Method 1: Using Webhook.update class method +updated_webhook = Uploadcare::Webhook.update( + webhook_id, + target_url: 'https://example.com/webhook/new', + event: 'file.stored', is_active: true, - signing_secret: 'webhook-secret' -} -Uploadcare::Webhook.update(webhook_id, options) + signing_secret: 'new_secret' +) + +puts "Webhook updated" +puts "New target: #{updated_webhook.target_url}" +puts "New event: #{updated_webhook.event}" + +# Method 2: Using instance method +webhook = Uploadcare::Webhook.list.find { |w| w.id == webhook_id } +webhook.update( + target_url: 'https://example.com/webhook/updated', + is_active: false +) +puts "Webhook deactivated" diff --git a/api_examples/update_examples.rb b/api_examples/update_examples.rb new file mode 100644 index 00000000..ede96ed4 --- /dev/null +++ b/api_examples/update_examples.rb @@ -0,0 +1,653 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Script to update all API examples to use the new API patterns + +require 'fileutils' + +# Define the new configuration header +NEW_CONFIG_HEADER = <<~RUBY +require 'uploadcare' + +# Configure API keys +Uploadcare.configure do |config| + config.public_key = 'YOUR_PUBLIC_KEY' + config.secret_key = 'YOUR_SECRET_KEY' +end +RUBY + +# Example transformations for each file type +EXAMPLES = { + 'delete_files_storage.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Delete file from storage +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Method 1: Using File resource +file = Uploadcare::File.new(uuid: uuid) +deleted_file = file.delete +puts "File deleted at: \#{deleted_file.datetime_removed}" + +# Method 2: Using client interface +client = Uploadcare.client +result = client.delete_file(uuid: uuid) +puts result.inspect + RUBY + + 'delete_files_uuid_storage.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Remove file from storage (but keep metadata) +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Using File resource +file = Uploadcare::File.new(uuid: uuid) +result = file.delete +puts "File removed from storage: \#{result.uuid}" +puts "Removal time: \#{result.datetime_removed}" + RUBY + + 'delete_files_uuid_metadata_key.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Delete specific metadata key from a file +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +key = 'custom_key' + +# Delete metadata key +result = Uploadcare::FileMetadata.delete(uuid, key) +puts "Metadata key '\#{key}' deleted from file \#{uuid}" + RUBY + + 'delete_groups_uuid.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Delete a file group +group_uuid = 'GROUP_UUID~2' + +# Method 1: Using Group resource +group = Uploadcare::Group.new(uuid: group_uuid) +group.delete +puts "Group deleted: \#{group_uuid}" + +# Note: Files in the group are not deleted, only the group itself + RUBY + + 'delete_webhooks_unsubscribe.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Delete/unsubscribe from a webhook +target_url = 'https://example.com/webhook/uploadcare' + +# Delete webhook by target URL +Uploadcare::Webhook.delete(target_url) +puts "Webhook unsubscribed: \#{target_url}" + RUBY + + 'get_addons_aws_rekognition_detect_labels_execute_status.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Check AWS Rekognition label detection status +request_id = 'REQUEST_ID_FROM_EXECUTE' + +# Check status +status = Uploadcare::AddOns.aws_rekognition_detect_labels_status(request_id) + +if status[:status] == 'done' + puts "Labels detected successfully" + # Labels are now available in file's appdata +elsif status[:status] == 'error' + puts "Detection failed: \#{status[:error]}" +else + puts "Detection in progress..." +end + RUBY + + 'get_convert_document_status_token.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Check document conversion status +token = 123456 # Token from conversion request + +# Check status +status = Uploadcare::DocumentConverter.status(token) + +case status[:status] +when 'finished' + puts "Conversion completed" + puts "Result UUID: \#{status[:result][:uuid]}" +when 'processing' + puts "Conversion in progress..." +when 'failed' + puts "Conversion failed: \#{status[:error]}" +end + RUBY + + 'get_convert_video_status_token.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Check video conversion status +token = 123456 # Token from conversion request + +# Check status +status = Uploadcare::VideoConverter.status(token) + +case status[:status] +when 'finished' + puts "Video conversion completed" + puts "Result UUID: \#{status[:result][:uuid]}" + puts "Thumbnails: \#{status[:result][:thumbnails_group_uuid]}" +when 'processing' + puts "Conversion in progress..." +when 'failed' + puts "Conversion failed: \#{status[:error]}" +end + RUBY + + 'get_groups.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# List file groups + +# Method 1: Using Group.list +groups = Uploadcare::Group.list + +groups.each do |group| + puts "Group ID: \#{group.id}" + puts "Files count: \#{group.files_count}" + puts "Created: \#{group.datetime_created}" + puts "---" +end + +# Method 2: Using client interface +client = Uploadcare.client +groups = client.list_groups +groups.each { |group| puts group.inspect } + RUBY + + 'get_groups_uuid.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Get group information +group_uuid = 'GROUP_UUID~2' + +# Method 1: Using Group resource +group = Uploadcare::Group.new(uuid: group_uuid) +info = group.info + +puts "Group ID: \#{info[:id]}" +puts "Files count: \#{info[:files_count]}" +puts "Files:" +info[:files].each do |file| + puts " - \#{file[:uuid]} (\#{file[:original_filename]})" +end + +# Method 2: Using client interface +client = Uploadcare.client +group_info = client.group_info(uuid: group_uuid) +puts group_info.inspect + RUBY + + 'get_project.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Get project information + +# Method 1: Using Project resource +project = Uploadcare::Project.show + +puts "Project name: \#{project.name}" +puts "Public key: \#{project.pub_key}" +puts "Autostore enabled: \#{project.autostore_enabled}" +puts "Collaborators: \#{project.collaborators.count}" + +# Method 2: Using client interface +client = Uploadcare.client +project_info = client.project_info +puts project_info.inspect + RUBY + + 'get_webhooks.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# List all webhooks + +# Method 1: Using Webhook.list +webhooks = Uploadcare::Webhook.list + +webhooks.each do |webhook| + puts "ID: \#{webhook.id}" + puts "Target URL: \#{webhook.target_url}" + puts "Event: \#{webhook.event}" + puts "Active: \#{webhook.is_active}" + puts "---" +end + +# Method 2: Using client interface +client = Uploadcare.client +webhooks = client.list_webhooks +webhooks.each { |webhook| puts webhook.inspect } + RUBY + + 'post_addons_aws_rekognition_detect_labels_execute.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Execute AWS Rekognition label detection +uuid = 'FILE_UUID' + +# Execute detection +result = Uploadcare::AddOns.aws_rekognition_detect_labels(uuid) +request_id = result[:request_id] + +puts "Detection started with request ID: \#{request_id}" +puts "Check status with: Uploadcare::AddOns.aws_rekognition_detect_labels_status('\#{request_id}')" + +# Results will be available in file's appdata when complete + RUBY + + 'post_addons_remove_bg_execute.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Remove background from image +uuid = 'FILE_UUID' + +# Execute background removal with options +result = Uploadcare::AddOns.remove_bg( + uuid, + crop: true, # Crop to object + type_level: '2', # Accuracy level (1 or 2) + type: 'person', # Object type: person, product, car + scale: '100%', # Output scale + position: 'center' # Crop position if cropping +) + +request_id = result[:request_id] +puts "Background removal started with request ID: \#{request_id}" + +# Check status +status = Uploadcare::AddOns.remove_bg_status(request_id) +if status[:status] == 'done' + puts "Result file UUID: \#{status[:result][:file_id]}" +end + RUBY + + 'post_addons_uc_clamav_virus_scan_execute.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Scan file for viruses +uuid = 'FILE_UUID' + +# Execute virus scan with auto-purge if infected +result = Uploadcare::AddOns.uc_clamav_virus_scan( + uuid, + purge_infected: true # Automatically delete if infected +) + +request_id = result[:request_id] +puts "Virus scan started with request ID: \#{request_id}" + +# Check status +status = Uploadcare::AddOns.uc_clamav_virus_scan_status(request_id) +if status[:status] == 'done' + # Check file's appdata for scan results + file = Uploadcare::File.new(uuid: uuid) + info = file.info(include: 'appdata') + scan_data = info[:appdata][:uc_clamav_virus_scan][:data] + + if scan_data[:infected] + puts "File infected with: \#{scan_data[:infected_with]}" + else + puts "File is clean" + end +end + RUBY + + 'post_convert_document.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Convert document to different format +uuid = 'DOCUMENT_UUID' + +# Check supported formats first +info = Uploadcare::DocumentConverter.info(uuid) +puts "Current format: \#{info[:format][:name]}" +puts "Can convert to: \#{info[:format][:conversion_formats].map { |f| f[:name] }.join(', ')}" + +# Convert document +result = Uploadcare::DocumentConverter.convert( + [ + { + uuid: uuid, + format: 'pdf', # Target format + page: 1 # For image outputs, specific page number + } + ], + store: true # Store the result +) + +token = result[:result].first[:token] +puts "Conversion started with token: \#{token}" + +# Check status +status = Uploadcare::DocumentConverter.status(token) +if status[:status] == 'finished' + puts "Converted file UUID: \#{status[:result][:uuid]}" +end + RUBY + + 'post_convert_video.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Convert video with various options +uuid = 'VIDEO_UUID' + +# Convert video +result = Uploadcare::VideoConverter.convert( + [ + { + uuid: uuid, + format: 'mp4', # Output format: mp4, webm, ogg + quality: 'normal', # Quality: normal, better, best, lighter, lightest + size: { + resize_mode: 'change_ratio', # preserve_ratio, change_ratio, scale_crop, add_padding + width: '1280', + height: '720' + }, + cut: { + start_time: '0:0:0.0', # Start time + length: '0:1:0.0' # Duration (or 'end') + }, + thumbs: { + N: 10, # Number of thumbnails + number: 1 # Specific thumbnail index + } + } + ], + store: true +) + +token = result[:result].first[:token] +uuid_result = result[:result].first[:uuid] +thumbnails = result[:result].first[:thumbnails_group_uuid] + +puts "Conversion started" +puts "Token: \#{token}" +puts "Result UUID: \#{uuid_result}" +puts "Thumbnails group: \#{thumbnails}" + +# Check status +status = Uploadcare::VideoConverter.status(token) +if status[:status] == 'finished' + puts "Video conversion completed!" +end + RUBY + + 'post_files_local_copy.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Create a local copy of a file +source_uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Create local copy +copied_file = Uploadcare::File.local_copy( + source_uuid, + store: true # Store the copy immediately +) + +puts "Original UUID: \#{source_uuid}" +puts "Copy UUID: \#{copied_file.uuid}" +puts "Copy URL: \#{copied_file.original_file_url}" + RUBY + + 'post_files_remote_copy.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Copy file to remote storage +source_uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +target_storage = 'my-s3-bucket' # Preconfigured storage name + +# Copy to remote storage +result = Uploadcare::File.remote_copy( + source_uuid, + target_storage, + make_public: true, # Make publicly accessible + pattern: 'uploads/\${year}/\${month}/\${filename}' # Optional path pattern +) + +puts "File copied to: \#{result}" + RUBY + + 'post_groups.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Create a file group +file_uuids = [ + '1bac376c-aa7e-4356-861b-dd2657b5bfd2', + 'a4b9db2f-1591-4f4c-8f68-94018924525d' +] + +# Method 1: Using Group.create +group = Uploadcare::Group.create(file_uuids) +puts "Group created with ID: \#{group.id}" +puts "Contains \#{group.files_count} files" + +# Method 2: Using client interface +client = Uploadcare.client +group = client.create_group(file_uuids) +puts group.inspect + RUBY + + 'post_webhooks.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Create a new webhook +webhook = Uploadcare::Webhook.create( + target_url: 'https://example.com/webhook/uploadcare', + event: 'file.uploaded', # Events: file.uploaded, file.stored, file.deleted, etc. + is_active: true, + signing_secret: 'your_webhook_secret', # For signature verification + version: '0.7' +) + +puts "Webhook created" +puts "ID: \#{webhook.id}" +puts "Target: \#{webhook.target_url}" +puts "Event: \#{webhook.event}" +puts "Active: \#{webhook.is_active}" + RUBY + + 'put_files_storage.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Batch store multiple files +uuids = [ + '1bac376c-aa7e-4356-861b-dd2657b5bfd2', + 'a4b9db2f-1591-4f4c-8f68-94018924525d' +] + +# Batch store +result = Uploadcare::File.batch_store(uuids) + +if result.status == 'success' + puts "Successfully stored \#{result.result.count} files:" + result.result.each do |file| + puts " - \#{file.uuid}: stored at \#{file.datetime_stored}" + end +end + +# Handle any problems +if result.problems.any? + puts "Problems encountered:" + result.problems.each do |uuid, error| + puts " - \#{uuid}: \#{error}" + end +end + RUBY + + 'put_files_uuid_storage.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Store a single file +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' + +# Method 1: Using File resource +file = Uploadcare::File.new(uuid: uuid) +stored_file = file.store +puts "File stored at: \#{stored_file.datetime_stored}" + +# Method 2: Using client interface +client = Uploadcare.client +result = client.store_file(uuid: uuid) +puts result.inspect + RUBY + + 'put_files_uuid_metadata_key.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Update file metadata +uuid = '1bac376c-aa7e-4356-861b-dd2657b5bfd2' +key = 'department' +value = 'marketing' + +# Update metadata +result = Uploadcare::FileMetadata.update(uuid, key, value) +puts "Metadata updated: \#{key} = \#{value}" + +# Retrieve metadata +metadata_value = Uploadcare::FileMetadata.show(uuid, key) +puts "Current value: \#{metadata_value}" + RUBY + + 'put_groups_uuid_storage.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Store all files in a group +group_uuid = 'GROUP_UUID~2' + +# Store group (stores all contained files) +Uploadcare::Group.store(group_uuid) +puts "Group and all its files have been stored" + RUBY + + 'put_webhooks_id.rb' => <<~RUBY +#{NEW_CONFIG_HEADER} +# Update an existing webhook +webhook_id = 123 # Webhook ID from creation or list + +# Method 1: Using Webhook.update class method +updated_webhook = Uploadcare::Webhook.update( + webhook_id, + target_url: 'https://example.com/webhook/new', + event: 'file.stored', + is_active: true, + signing_secret: 'new_secret' +) + +puts "Webhook updated" +puts "New target: \#{updated_webhook.target_url}" +puts "New event: \#{updated_webhook.event}" + +# Method 2: Using instance method +webhook = Uploadcare::Webhook.list.find { |w| w.id == webhook_id } +webhook.update( + target_url: 'https://example.com/webhook/updated', + is_active: false +) +puts "Webhook deactivated" + RUBY +} + +# Process each example file +Dir.glob('api_examples/rest_api/*.rb').each do |file| + filename = File.basename(file) + + if EXAMPLES.key?(filename) + puts "Updating #{filename}..." + File.write(file, EXAMPLES[filename]) + else + puts "Skipping #{filename} (no transformation defined)" + end +end + +puts "\nUpdating upload API examples..." + +# Upload API examples +UPLOAD_EXAMPLES = { + 'upload_from_url.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Upload file from URL + +# Synchronous upload (waits for completion) +file = Uploadcare::Uploader.upload_from_url( + 'https://example.com/image.jpg', + store: 'auto' # auto, true, or false +) + +puts "File uploaded:" +puts "UUID: \#{file.uuid}" +puts "URL: \#{file.original_file_url}" + +# Asynchronous upload (returns immediately) +token = Uploadcare::Uploader.upload_from_url( + 'https://example.com/large-file.zip', + async: true, + store: true +) + +puts "Upload started with token: \#{token}" + +# Check async upload status +status = Uploadcare::Uploader.get_upload_from_url_status(token) +case status[:status] +when 'success' + puts "Upload complete: \#{status[:uuid]}" +when 'error' + puts "Upload failed: \#{status[:error]}" +when 'progress' + percent = (status[:done].to_f / status[:total] * 100).round(2) + puts "Upload progress: \#{percent}%" +end + RUBY + + 'upload_file.rb' => <<~RUBY, +#{NEW_CONFIG_HEADER} +# Upload local file + +# From file path +file = File.open('path/to/image.jpg') +uploaded = Uploadcare::Uploader.upload( + file, + store: true, + metadata: { + source: 'api_example', + user_id: '123' + } +) + +puts "File uploaded:" +puts "UUID: \#{uploaded.uuid}" +puts "URL: \#{uploaded.original_file_url}" +puts "Size: \#{uploaded.size} bytes" + +file.close + +# From string/IO +require 'stringio' +content = StringIO.new("Hello, Uploadcare!") +uploaded = Uploadcare::Uploader.upload(content, store: false) +puts "String uploaded: \#{uploaded.uuid}" + RUBY + + 'multipart_upload.rb' => <<~RUBY +#{NEW_CONFIG_HEADER} +# Multipart upload for large files (>100MB) + +large_file = File.open('path/to/large-video.mp4') + +# Upload with progress tracking +uploaded = Uploadcare::Uploader.multipart_upload( + large_file, + store: true, + metadata: { + type: 'video', + duration: '01:23:45' + } +) do |progress| + percent = (progress[:offset].to_f / progress[:object].size * 100).round(2) + puts "Upload progress: \#{percent}% (chunk \#{progress[:link_id] + 1}/\#{progress[:links_count]})" +end + +puts "Upload complete!" +puts "UUID: \#{uploaded.uuid}" +puts "URL: \#{uploaded.original_file_url}" + +large_file.close + RUBY +} + +Dir.glob('api_examples/upload_api/*.rb').each do |file| + filename = File.basename(file) + + if UPLOAD_EXAMPLES.key?(filename) + puts "Updating #{filename}..." + File.write(file, UPLOAD_EXAMPLES[filename]) + end +end + +puts "\nAll API examples have been updated!" \ No newline at end of file diff --git a/bin/console b/bin/console index 5763de1b..d6e9fc9e 100755 --- a/bin/console +++ b/bin/console @@ -2,7 +2,7 @@ # frozen_string_literal: true require 'bundler/setup' -require 'uploadcare/ruby' +require 'uploadcare' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. diff --git a/lib/uploadcare.rb b/lib/uploadcare.rb index 533a5dd0..18935418 100644 --- a/lib/uploadcare.rb +++ b/lib/uploadcare.rb @@ -1,54 +1,43 @@ # frozen_string_literal: true -# Gem version -require 'ruby/version' - -# Exceptions -require 'exception/throttle_error' -require 'exception/request_error' -require 'exception/retry_error' -require 'exception/auth_error' - -# Entities -require 'entity/entity' -require 'entity/file' -require 'entity/file_list' -require 'entity/group' -require 'entity/group_list' -require 'entity/project' -require 'entity/uploader' -require 'entity/webhook' - -# Param -require 'param/webhook_signature_verifier' - -# General api -require 'api/api' - -# SignedUrlGenerators -require 'signed_url_generators/akamai_generator' -require 'signed_url_generators/base_generator' +require 'zeitwerk' +require 'faraday' +require_relative 'uploadcare/errors' # Ruby wrapper for Uploadcare API # # @see https://uploadcare.com/docs/api_reference module Uploadcare - extend Dry::Configurable - - setting :public_key, default: ENV.fetch('UPLOADCARE_PUBLIC_KEY', '') - setting :secret_key, default: ENV.fetch('UPLOADCARE_SECRET_KEY', '') - setting :auth_type, default: 'Uploadcare' - setting :multipart_size_threshold, default: 100 * 1024 * 1024 - setting :rest_api_root, default: 'https://api.uploadcare.com' - setting :upload_api_root, default: 'https://upload.uploadcare.com' - setting :max_request_tries, default: 100 - setting :base_request_sleep, default: 1 # seconds - setting :max_request_sleep, default: 60.0 # seconds - setting :sign_uploads, default: false - setting :upload_signature_lifetime, default: 30 * 60 # seconds - setting :max_throttle_attempts, default: 5 - setting :upload_threads, default: 2 # used for multiupload only ATM - setting :framework_data, default: '' - setting :file_chunk_size, default: 100 - setting :logger, default: Logger.new($stdout) + @loader = Zeitwerk::Loader.for_gem + @loader.collapse("#{__dir__}/uploadcare/resources") + @loader.collapse("#{__dir__}/uploadcare/clients") + @loader.setup + + class << self + def configure + yield configuration if block_given? + end + + def configuration + @configuration ||= Configuration.new + end + + def eager_load! + @loader.eager_load + end + + def api(config = nil) + Api.new(config || configuration) + end + + # Create a new client instance with optional configuration + def client(options = {}) + Client.new(options) + end + + # Convenience method to build URLs + def url_builder(source) + UrlBuilder.new(source, configuration) + end + end end diff --git a/lib/uploadcare/api.rb b/lib/uploadcare/api.rb new file mode 100644 index 00000000..ef9192b5 --- /dev/null +++ b/lib/uploadcare/api.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module Uploadcare + class Api + attr_reader :config + + def initialize(config = nil) + @config = config || Uploadcare.configuration + end + + # File operations + def file(uuid) + File.new({ uuid: uuid }, config).info + end + + def file_list(options = {}) + File.list(options, config) + end + + def store_file(uuid) + File.new({ uuid: uuid }, config).store + end + + def delete_file(uuid) + File.new({ uuid: uuid }, config).delete + end + + def batch_store(uuids) + File.batch_store(uuids, config) + end + + def batch_delete(uuids) + File.batch_delete(uuids, config) + end + + def local_copy(source, options = {}) + File.local_copy(source, options, config) + end + + def remote_copy(source, target, options = {}) + File.remote_copy(source, target, options, config) + end + + # Upload operations + def upload(input, options = {}) + Uploader.upload(input, options, config) + end + + def upload_file(file, options = {}) + Uploader.upload_file(file, options, config) + end + + def upload_files(files, options = {}) + Uploader.upload_files(files, options, config) + end + + def upload_from_url(url, options = {}) + Uploader.upload_from_url(url, options, config) + end + + def check_upload_status(token) + Uploader.check_upload_status(token, config) + end + + # Group operations + def group(uuid) + Group.new({ id: uuid }, config).info(uuid) + end + + def group_list(options = {}) + Group.list(options, config) + end + + def create_group(files, options = {}) + Group.create(files, options, config) + end + + def store_group(uuid) + Group.new({ id: uuid }, config).store + end + + def delete_group(uuid) + Group.new({ id: uuid }, config).delete + end + + # Project operations + def project + Project.info(config) + end + + # Webhook operations + def create_webhook(target_url, options = {}) + Webhook.create({ target_url: target_url }.merge(options), config) + end + + def list_webhooks(options = {}) + Webhook.list(options, config) + end + + def update_webhook(id, options = {}) + webhook = Webhook.new({ id: id }, config) + webhook.update(options) + end + + def delete_webhook(target_url) + Webhook.delete(target_url, config) + end + + # Document conversion + def convert_document(paths, options = {}) + DocumentConverter.convert(paths, options, config) + end + + def document_conversion_status(token) + DocumentConverter.status(token, config) + end + + # Video conversion + def convert_video(paths, options = {}) + VideoConverter.convert(paths, options, config) + end + + def video_conversion_status(token) + VideoConverter.status(token, config) + end + + # Add-ons operations + def execute_addon(addon_name, target, options = {}) + AddOns.execute(addon_name, target, options, config) + end + + def check_addon_status(addon_name, request_id) + AddOns.status(addon_name, request_id, config) + end + + # File metadata operations + def file_metadata(uuid) + FileMetadata.index(uuid, config) + end + + def get_file_metadata(uuid, key) + FileMetadata.show(uuid, key, config) + end + + def update_file_metadata(uuid, key, value) + FileMetadata.update(uuid, key, value, config) + end + + def delete_file_metadata(uuid, key) + FileMetadata.delete(uuid, key, config) + end + end +end diff --git a/lib/uploadcare/api/api.rb b/lib/uploadcare/api/api.rb deleted file mode 100644 index 8834cd3b..00000000 --- a/lib/uploadcare/api/api.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -Gem.find_files('client/**/*.rb').each { |path| require path } -Gem.find_files('entity/**/*.rb').each { |path| require path } - -module Uploadcare - # End-user interface - # - # It delegates methods to other classes: - # * To class methods of Entity objects - # * To instance methods of Client objects - # @see Uploadcare::Entity - # @see Uploadcare::Client - class Api - extend Forwardable - include Entity - - def_delegator File, :file - def_delegators FileList, :file_list, :store_files, :delete_files - def_delegators Group, :group - def_delegators Project, :project - def_delegators Uploader, :upload, :upload_files, :upload_url - def_delegators Webhook, :create_webhook, :list_webhooks, :delete_webhook, :update_webhook - end -end diff --git a/lib/uploadcare/authenticator.rb b/lib/uploadcare/authenticator.rb new file mode 100644 index 00000000..d45c725f --- /dev/null +++ b/lib/uploadcare/authenticator.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'digest/md5' +require 'addressable/uri' +require 'openssl' +require 'time' + +module Uploadcare + class Authenticator + attr_reader :default_headers + + def initialize(config) + @config = config + @default_headers = { + 'Accept' => 'application/vnd.uploadcare-v0.7+json', + 'Content-Type' => 'application/json' + } + end + + def headers(http_method, uri, body = '', content_type = 'application/json') + return simple_auth_headers if @config.auth_type == 'Uploadcare.Simple' + + date = Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT') + sign_string = [ + http_method.upcase, + Digest::MD5.hexdigest(body), + content_type, + date, + uri + ].join("\n") + + signature = OpenSSL::HMAC.hexdigest( + OpenSSL::Digest.new('sha1'), + @config.secret_key, + sign_string + ) + + auth_headers = { 'Authorization' => "Uploadcare #{@config.public_key}:#{signature}", 'Date' => date } + @default_headers.merge(auth_headers) + end + + private + + def simple_auth_headers + @default_headers.merge({ 'Authorization' => "#{@config.auth_type} #{@config.public_key}:#{@config.secret_key}" }) + end + end +end diff --git a/lib/uploadcare/client.rb b/lib/uploadcare/client.rb new file mode 100644 index 00000000..c631ca1c --- /dev/null +++ b/lib/uploadcare/client.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +module Uploadcare + class Client + attr_reader :config + + def initialize(options = {}) + @config = if options.is_a?(Configuration) + options + else + Configuration.new(options) + end + @middleware = [] + setup_default_middleware + end + + # Resource accessors + def files + @files ||= FileResource.new(self) + end + + def uploads + @uploads ||= UploadResource.new(self) + end + + def groups + @groups ||= GroupResource.new(self) + end + + def projects + @projects ||= ProjectResource.new(self) + end + + def webhooks + @webhooks ||= WebhookResource.new(self) + end + + def add_ons + @add_ons ||= AddOnResource.new(self) + end + + # Add middleware + def use(middleware, options = {}) + @middleware << { klass: middleware, options: options } + self + end + + # Remove middleware + def remove(middleware_class) + @middleware.reject! { |m| m[:klass] == middleware_class } + self + end + + # Execute request with middleware stack + def request(method, url, options = {}) + env = build_env(method, url, options) + + # Build middleware stack + stack = @middleware.reduce(base_app) do |app, middleware| + middleware[:klass].new(app, middleware[:options]) + end + + stack.call(env) + end + + private + + def setup_default_middleware + use(Middleware::Retry) if config.max_request_tries > 1 + use(Middleware::Logger, config.logger) if config.logger + end + + def build_env(method, url, options) + { + method: method, + url: url, + request_headers: options[:headers] || {}, + body: options[:body], + params: options[:params], + config: config + } + end + + def base_app + ->(env) { execute_request(env) } + end + + def execute_request(_env) + # Actual HTTP request execution + # This would be implemented based on the specific HTTP library used + # For now, returning a mock response structure + { + status: 200, + headers: {}, + body: {} + } + end + + # Resource wrapper classes + class FileResource + def initialize(client) + @client = client + end + + def list(options = {}) + Uploadcare::File.list(options, @client.config) + end + + def find(uuid) + Uploadcare::File.new({ uuid: uuid }, @client.config).info + end + + def store(uuid) + Uploadcare::File.new({ uuid: uuid }, @client.config).store + end + + def delete(uuid) + Uploadcare::File.new({ uuid: uuid }, @client.config).delete + end + + def batch_store(uuids) + Uploadcare::File.batch_store(uuids, @client.config) + end + + def batch_delete(uuids) + Uploadcare::File.batch_delete(uuids, @client.config) + end + + def local_copy(source, options = {}) + Uploadcare::File.local_copy(source, options, @client.config) + end + + def remote_copy(source, target, options = {}) + Uploadcare::File.remote_copy(source, target, options, @client.config) + end + end + + class UploadResource + def initialize(client) + @client = client + end + + def upload(input, options = {}) + Uploadcare::Uploader.upload(input, options, @client.config) + end + + def from_url(url, options = {}) + Uploadcare::Uploader.upload_from_url(url, options, @client.config) + end + + def from_file(file, options = {}) + Uploadcare::Uploader.upload_file(file, options, @client.config) + end + + def multiple(files, options = {}) + Uploadcare::Uploader.upload_files(files, options, @client.config) + end + + def status(token) + Uploadcare::Uploader.check_upload_status(token, @client.config) + end + end + + class GroupResource + def initialize(client) + @client = client + end + + def list(options = {}) + Uploadcare::Group.list(options, @client.config) + end + + def find(uuid) + Uploadcare::Group.new({ id: uuid }, @client.config).info(uuid) + end + + def create(files, options = {}) + Uploadcare::Group.create(files, options, @client.config) + end + + def delete(uuid) + Uploadcare::Group.new({ id: uuid }, @client.config).delete(uuid) + end + end + + class ProjectResource + def initialize(client) + @client = client + end + + def info + Uploadcare::Project.info(@client.config) + end + end + + class WebhookResource + def initialize(client) + @client = client + end + + def list(options = {}) + Uploadcare::Webhook.list(options, @client.config) + end + + def create(target_url, options = {}) + Uploadcare::Webhook.create({ target_url: target_url }.merge(options), @client.config) + end + + def update(id, options = {}) + webhook = Uploadcare::Webhook.new({ id: id }, @client.config) + webhook.update(options) + end + + def delete(target_url) + Uploadcare::Webhook.delete(target_url, @client.config) + end + end + + class AddOnResource + def initialize(client) + @client = client + end + + def execute(addon_name, target, options = {}) + Uploadcare::AddOns.execute(addon_name, target, options, @client.config) + end + + def status(addon_name, request_id) + Uploadcare::AddOns.status(addon_name, request_id, @client.config) + end + end + end +end diff --git a/lib/uploadcare/client/addons_client.rb b/lib/uploadcare/client/addons_client.rb deleted file mode 100644 index c60c7bef..00000000 --- a/lib/uploadcare/client/addons_client.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # API client for handling uploaded files - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons - class AddonsClient < RestClient - # Execute ClamAV virus checking Add-On for a given target. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/ucClamavVirusScanExecute - def uc_clamav_virus_scan(uuid, params = {}) - content = { target: uuid, params: params }.to_json - post(uri: '/addons/uc_clamav_virus_scan/execute/', content: content) - end - - # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/ucClamavVirusScanExecutionStatus - def uc_clamav_virus_scan_status(uuid) - get(uri: "/addons/uc_clamav_virus_scan/execute/status/#{query_params(uuid)}") - end - - # Execute AWS Rekognition Add-On for a given target to detect labels in an image. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/awsRekognitionExecute - def ws_rekognition_detect_labels(uuid) - content = { target: uuid }.to_json - post(uri: '/addons/aws_rekognition_detect_labels/execute/', content: content) - end - - # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/awsRekognitionExecutionStatus - def ws_rekognition_detect_labels_status(uuid) - get(uri: "/addons/aws_rekognition_detect_labels/execute/status/#{query_params(uuid)}") - end - - # Execute remove.bg background image removal Add-On for a given target. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/removeBgExecute - def remove_bg(uuid, params = {}) - content = { target: uuid, params: params }.to_json - post(uri: '/addons/remove_bg/execute/', content: content) - end - - # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/removeBgExecutionStatus - def remove_bg_status(uuid) - get(uri: "/addons/remove_bg/execute/status/#{query_params(uuid)}") - end - - # Execute AWS Rekognition Moderation Add-On for a given target to detect labels in an image. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecute - def ws_rekognition_detect_moderation_labels(uuid) - content = { target: uuid }.to_json - post(uri: '/addons/aws_rekognition_detect_moderation_labels/execute/', content: content) - end - - # Check the status of an Add-On execution request that had been started using the Execute Add-On operation. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecutionStatus - def ws_rekognition_detect_moderation_labels_status(uuid) - get(uri: "/addons/aws_rekognition_detect_moderation_labels/execute/status/#{query_params(uuid)}") - end - - private - - def query_params(uuid) - "?#{URI.encode_www_form(request_id: uuid)}" - end - end - end -end diff --git a/lib/uploadcare/client/conversion/base_conversion_client.rb b/lib/uploadcare/client/conversion/base_conversion_client.rb deleted file mode 100644 index 8946bb33..00000000 --- a/lib/uploadcare/client/conversion/base_conversion_client.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require_relative '../rest_client' -require 'exception/conversion_error' - -module Uploadcare - module Client - module Conversion - # This is a base client for conversion operations - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion - class BaseConversionClient < RestClient - API_VERSION_HEADER_VALUE = 'application/vnd.uploadcare-v0.7+json' - - def headers - { - 'Content-Type': 'application/json', - Accept: API_VERSION_HEADER_VALUE, - 'User-Agent': Uploadcare::Param::UserAgent.call - } - end - - private - - def send_convert_request(arr, options, url_builder_class) - body = build_body_for_many(arr, options, url_builder_class) - post(uri: convert_uri, content: body) - end - - def success(response) - body = response.body.to_s - extract_result(body) - end - - def extract_result(response_body) - return if response_body.empty? - - parsed_body = JSON.parse(response_body, symbolize_names: true) - errors = parsed_body[:error] || parsed_body[:problems] - return Dry::Monads::Result::Failure.call(errors) unless errors.nil? || errors.empty? - - Dry::Monads::Result::Success.call(parsed_body) - end - - # Prepares body for convert_many method - def build_body_for_many(arr, options, url_builder_class) - { - paths: arr.map do |params| - url_builder_class.call( - **build_paths_body(params) - ) - end, - store: options[:store], - save_in_group: options[:save_in_group] - }.compact.to_json - end - end - end - end -end diff --git a/lib/uploadcare/client/conversion/document_conversion_client.rb b/lib/uploadcare/client/conversion/document_conversion_client.rb deleted file mode 100644 index 07ff6d45..00000000 --- a/lib/uploadcare/client/conversion/document_conversion_client.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'client/conversion/base_conversion_client' -require 'param/conversion/document/processing_job_url_builder' - -module Uploadcare - module Client - module Conversion - # This is client for document conversion - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/documentConvert - class DocumentConversionClient < BaseConversionClient - def convert_many( - arr, - options = {}, - url_builder_class = Param::Conversion::Document::ProcessingJobUrlBuilder - ) - send_convert_request(arr, options, url_builder_class) - end - - def get_conversion_status(token) - get(uri: "/convert/document/status/#{token}/") - end - - def document_info(uuid) - get(uri: "/convert/document/#{uuid}/") - end - - private - - def convert_uri - '/convert/document/' - end - - def build_paths_body(params) - { - uuid: params[:uuid], - format: params[:format], - page: params[:page] - }.compact - end - end - end - end -end diff --git a/lib/uploadcare/client/conversion/video_conversion_client.rb b/lib/uploadcare/client/conversion/video_conversion_client.rb deleted file mode 100644 index cb11a367..00000000 --- a/lib/uploadcare/client/conversion/video_conversion_client.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'client/conversion/base_conversion_client' -require 'param/conversion/video/processing_job_url_builder' -require 'exception/conversion_error' - -module Uploadcare - module Client - module Conversion - # This is client for video conversion - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/videoConvert - class VideoConversionClient < BaseConversionClient - def convert_many( - params, - options = {}, - url_builder_class = Param::Conversion::Video::ProcessingJobUrlBuilder - ) - video_params = params.is_a?(Hash) ? [params] : params - send_convert_request(video_params, options, url_builder_class) - end - - def get_conversion_status(token) - get(uri: "/convert/video/status/#{token}/") - end - - private - - def convert_uri - '/convert/video/' - end - - def build_paths_body(params) - { - uuid: params[:uuid], - quality: params[:quality], - format: params[:format], - size: params[:size], - cut: params[:cut], - thumbs: params[:thumbs] - }.compact - end - end - end - end -end diff --git a/lib/uploadcare/client/file_client.rb b/lib/uploadcare/client/file_client.rb deleted file mode 100644 index 69f14bf0..00000000 --- a/lib/uploadcare/client/file_client.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # API client for handling single files - # @see https://uploadcare.com/docs/api_reference/rest/accessing_files/ - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File - class FileClient < RestClient - # Gets list of files without pagination fields - def index - response = get(uri: '/files/') - response.fmap { |i| i[:results] } - end - - # Acquire file info - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/fileInfo - def info(uuid, params = {}) - get(uri: "/files/#{uuid}/", params: params) - end - alias file info - - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/createLocalCopy - def local_copy(options = {}) - body = options.compact.to_json - post(uri: '/files/local_copy/', content: body) - end - - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/createRemoteCopy - def remote_copy(options = {}) - body = options.compact.to_json - post(uri: '/files/remote_copy/', content: body) - end - - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/deleteFileStorage - def delete(uuid) - request(method: 'DELETE', uri: "/files/#{uuid}/storage/") - end - - # Store a single file, preventing it from being deleted in 2 weeks - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/storeFile - def store(uuid) - put(uri: "/files/#{uuid}/storage/") - end - end - end -end diff --git a/lib/uploadcare/client/file_list_client.rb b/lib/uploadcare/client/file_list_client.rb deleted file mode 100644 index 9e22f9bd..00000000 --- a/lib/uploadcare/client/file_list_client.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # API client for handling file lists - class FileListClient < RestClient - # Returns a pagination json of files stored in project - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/filesList - # - # valid options: - # removed: [true|false] - # stored: [true|false] - # limit: (1..1000) - # ordering: ["datetime_uploaded"|"-datetime_uploaded"] - # from: number of files skipped - def file_list(options = {}) - query = options.empty? ? '' : "?#{URI.encode_www_form(options)}" - get(uri: "/files/#{query}") - end - - # Make a set of files "stored". This will prevent them from being deleted automatically - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/filesStoring - # uuids: Array - def batch_store(uuids) - body = uuids.to_json - put(uri: '/files/storage/', content: body) - end - - alias request_delete delete - - # Delete several files by list of uids - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/filesDelete - # uuids: Array - def batch_delete(uuids) - body = uuids.to_json - request_delete(uri: '/files/storage/', content: body) - end - - alias store_files batch_store - alias delete_files batch_delete - alias list file_list - end - end -end diff --git a/lib/uploadcare/client/file_metadata_client.rb b/lib/uploadcare/client/file_metadata_client.rb deleted file mode 100644 index f445d61b..00000000 --- a/lib/uploadcare/client/file_metadata_client.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # API client for handling single metadata_files - # @see https://uploadcare.com/docs/file-metadata/ - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata - class FileMetadataClient < RestClient - # Get file's metadata keys and values - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/fileMetadata - def index(uuid) - get(uri: "/files/#{uuid}/metadata/") - end - - # Get the value of a single metadata key. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/fileMetadataKey - def show(uuid, key) - get(uri: "/files/#{uuid}/metadata/#{key}/") - end - - # Update the value of a single metadata key. If the key does not exist, it will be created. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/updateFileMetadataKey - def update(uuid, key, value) - put(uri: "/files/#{uuid}/metadata/#{key}/", content: value.to_json) - end - - # Delete a file's metadata key. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/deleteFileMetadataKey - def delete(uuid, key) - request(method: 'DELETE', uri: "/files/#{uuid}/metadata/#{key}/") - end - end - end -end diff --git a/lib/uploadcare/client/group_client.rb b/lib/uploadcare/client/group_client.rb deleted file mode 100644 index edf2218f..00000000 --- a/lib/uploadcare/client/group_client.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require_relative 'upload_client' - -module Uploadcare - module Client - # Groups serve a purpose of better organizing files in your Uploadcare projects. - # You can create one from a set of files by using their UUIDs. - # @see https://uploadcare.com/docs/api_reference/upload/groups/ - class GroupClient < UploadClient - # Create files group from a set of files by using their UUIDs. - # @see https://uploadcare.com/api-refs/upload-api/#operation/createFilesGroup - def create(file_list, options = {}) - body_hash = group_body_hash(file_list, options) - body = HTTP::FormData::Multipart.new(body_hash) - post(path: 'group/', - headers: { 'Content-Type': body.content_type }, - body: body) - end - - # Get group info - # @see https://uploadcare.com/api-refs/upload-api/#operation/filesGroupInfo - def info(group_id) - get(path: 'group/info/', params: { pub_key: Uploadcare.config.public_key, group_id: group_id }) - end - - private - - def file_params(file_ids) - ids = (0...file_ids.size).map { |i| "files[#{i}]" } - ids.zip(file_ids).to_h - end - - def group_body_hash(file_list, options = {}) - { pub_key: Uploadcare.config.public_key }.merge(file_params(parse_file_list(file_list))).merge(options) - end - - # API accepts only list of ids, but some users may want to upload list of files - # @return [Array] of [String] - def parse_file_list(file_list) - file_list.map { |file| file.methods.include?(:uuid) ? file.uuid : file } - end - end - end -end diff --git a/lib/uploadcare/client/multipart_upload/chunks_client.rb b/lib/uploadcare/client/multipart_upload/chunks_client.rb deleted file mode 100644 index 452e80ba..00000000 --- a/lib/uploadcare/client/multipart_upload/chunks_client.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'parallel' -require 'dry/monads' -require 'api_struct' - -module Uploadcare - module Client - module MultipartUpload - # This class splits file into chunks of set chunk_size - # and uploads them into cloud storage. - # Used for multipart uploads - # @see https://uploadcare.com/api-refs/upload-api/#tag/Upload/paths/https:~1~1uploadcare.s3-accelerate.amazonaws.com~1%3C%3Cpresigned-url%3E/put - class ChunksClient < ApiStruct::Client - CHUNK_SIZE = 5_242_880 - - # In multiple threads, split file into chunks and upload those chunks into respective Amazon links - # @param object [File] - # @param links [Array] of strings; by default list of Amazon storage urls - def self.upload_chunks(object, links) - Parallel.each(0...links.count, in_threads: Uploadcare.config.upload_threads) do |link_id| - offset = link_id * CHUNK_SIZE - chunk = File.read(object, CHUNK_SIZE, offset) - new.upload_chunk(chunk, links[link_id]) - next unless block_given? - - yield( - chunk_size: CHUNK_SIZE, - object: object, - offset: offset, - link_id: link_id, - links: links, - links_count: links.count - ) - end - end - - def api_root - '' - end - - def headers - {} - end - - def upload_chunk(chunk, link) - put(path: link, body: chunk, headers: { 'Content-Type': 'application/octet-stream' }) - end - - private - - def default_params - {} - end - end - end - end -end diff --git a/lib/uploadcare/client/multipart_upload_client.rb b/lib/uploadcare/client/multipart_upload_client.rb deleted file mode 100644 index f5792c7e..00000000 --- a/lib/uploadcare/client/multipart_upload_client.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'client/multipart_upload/chunks_client' -require_relative 'upload_client' - -module Uploadcare - module Client - # Client for multipart uploads - # - # @see https://uploadcare.com/api-refs/upload-api/#tag/Upload - class MultipartUploaderClient < UploadClient - include MultipartUpload - - # Upload a big file by splitting it into parts and sending those parts into assigned buckets - # object should be File - def upload(object, options = {}, &block) - response = upload_start(object, options) - return response unless response.success[:parts] && response.success[:uuid] - - links = response.success[:parts] - uuid = response.success[:uuid] - ChunksClient.upload_chunks(object, links, &block) - upload_complete(uuid) - end - - # Asks Uploadcare server to create a number of storage bin for uploads - def upload_start(object, options = {}) - body = HTTP::FormData::Multipart.new( - Param::Upload::UploadParamsGenerator.call(options).merge(form_data_for(object)) - ) - post(path: 'multipart/start/', - headers: { 'Content-Type': body.content_type }, - body: body) - end - - # When every chunk is uploaded, ask Uploadcare server to finish the upload - def upload_complete(uuid) - body = HTTP::FormData::Multipart.new( - { - UPLOADCARE_PUB_KEY: Uploadcare.config.public_key, - uuid: uuid - } - ) - post(path: 'multipart/complete/', body: body, headers: { 'Content-Type': body.content_type }) - end - - private - - def form_data_for(file) - form_data_file = super - { - filename: form_data_file.filename, - size: form_data_file.size, - content_type: form_data_file.content_type - } - end - - alias api_struct_post post - def post(**args) - handle_throttling { api_struct_post(**args) } - end - end - end -end diff --git a/lib/uploadcare/client/project_client.rb b/lib/uploadcare/client/project_client.rb deleted file mode 100644 index 757c18ae..00000000 --- a/lib/uploadcare/client/project_client.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # API client for getting project info - # @see https://uploadcare.com/docs/api_reference/rest/handling_projects/ - class ProjectClient < RestClient - # get information about current project - # current project is determined by public and secret key combination - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Project - def show - get(uri: '/project/') - end - - alias project show - end - end -end diff --git a/lib/uploadcare/client/rest_client.rb b/lib/uploadcare/client/rest_client.rb deleted file mode 100644 index a7ae8035..00000000 --- a/lib/uploadcare/client/rest_client.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require 'dry/monads' -require 'api_struct' -require 'uploadcare/concern/error_handler' -require 'uploadcare/concern/throttle_handler' -require 'param/authentication_header' - -module Uploadcare - module Client - # @abstract - # General client for signed REST requests - class RestClient < ApiStruct::Client - include Uploadcare::Concerns::ErrorHandler - include Uploadcare::Concerns::ThrottleHandler - include Exception - - alias api_struct_delete delete - alias api_struct_get get - alias api_struct_post post - alias api_struct_put put - - # Send request with authentication header - # - # Handle throttling as well - def request(uri:, method: 'GET', **options) - request_headers = Param::AuthenticationHeader.call(method: method.upcase, uri: uri, - content_type: headers[:'Content-Type'], **options) - handle_throttling do - send("api_struct_#{method.downcase}", - path: remove_trailing_slash(uri), - headers: request_headers, - body: options[:content], - params: options[:params]) - end - end - - def get(options = {}) - request(method: 'GET', **options) - end - - def post(options = {}) - request(method: 'POST', **options) - end - - def put(options = {}) - request(method: 'PUT', **options) - end - - def delete(options = {}) - request(method: 'DELETE', **options) - end - - def api_root - Uploadcare.config.rest_api_root - end - - def headers - { - 'Content-Type': 'application/json', - Accept: 'application/vnd.uploadcare-v0.7+json', - 'User-Agent': Uploadcare::Param::UserAgent.call - } - end - - private - - def remove_trailing_slash(str) - str.gsub(%r{^/}, '') - end - - def default_params - {} - end - end - end -end diff --git a/lib/uploadcare/client/rest_group_client.rb b/lib/uploadcare/client/rest_group_client.rb deleted file mode 100644 index c407a0f2..00000000 --- a/lib/uploadcare/client/rest_group_client.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/paths/~1groups~1%3Cuuid%3E~1storage~1/put - class RestGroupClient < RestClient - # store all files in a group - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/storeFile - def store(uuid) - files = info(uuid).success[:files].compact - client = ::Uploadcare::Client::FileClient.new - files.each_slice(Uploadcare.config.file_chunk_size) do |file_chunk| - file_chunk.each do |file| - client.store(file[:uuid]) - end - end - - Dry::Monads::Result::Success.call(nil) - end - - # Get a file group by its ID. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/groupInfo - def info(uuid) - get(uri: "/groups/#{uuid}/") - end - - # return paginated list of groups - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/groupsList - def list(options = {}) - query = options.empty? ? '' : "?#{URI.encode_www_form(options)}" - get(uri: "/groups/#{query}") - end - - # Delete a file group by its ID. - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/deleteGroup - def delete(uuid) - request(method: 'DELETE', uri: "/groups/#{uuid}/") - end - end - end -end diff --git a/lib/uploadcare/client/upload_client.rb b/lib/uploadcare/client/upload_client.rb deleted file mode 100644 index b294ed01..00000000 --- a/lib/uploadcare/client/upload_client.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'dry/monads' -require 'api_struct' -require 'param/user_agent' -require 'uploadcare/concern/error_handler' -require 'uploadcare/concern/throttle_handler' -require 'mimemagic' - -module Uploadcare - module Client - # @abstract - # - # Headers and helper methods for clients working with upload API - # @see https://uploadcare.com/docs/api_reference/upload/ - class UploadClient < ApiStruct::Client - include Concerns::ErrorHandler - include Concerns::ThrottleHandler - include Exception - - def api_root - Uploadcare.config.upload_api_root - end - - def headers - { - 'User-Agent': Uploadcare::Param::UserAgent.call - } - end - - private - - def form_data_for(file) - filename = file.original_filename if file.respond_to?(:original_filename) - mime_type = MimeMagic.by_magic(file)&.type - mime_type = file.content_type if mime_type.nil? && file.respond_to?(:content_type) - options = { filename: filename, content_type: mime_type }.compact - HTTP::FormData::File.new(file, options) - end - - def default_params - {} - end - end - end -end diff --git a/lib/uploadcare/client/uploader_client.rb b/lib/uploadcare/client/uploader_client.rb deleted file mode 100644 index b0424c33..00000000 --- a/lib/uploadcare/client/uploader_client.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -require_relative 'upload_client' -require 'retries' -require 'param/upload/upload_params_generator' -require 'param/upload/signature_generator' - -module Uploadcare - module Client - # This is client for general uploads - # - # @see https://uploadcare.com/api-refs/upload-api/#tag/Upload - class UploaderClient < UploadClient - # @see https://uploadcare.com/api-refs/upload-api/#operation/baseUpload - - def upload_many(arr, options = {}) - body = upload_many_body(arr, options) - post(path: 'base/', - headers: { 'Content-Type': body.content_type }, - body: body) - end - - # syntactic sugar for upload_many - # There is actual upload method for one file, but it is redundant - - def upload(file, options = {}) - upload_many([file], options) - end - - # Upload files from url - # @see https://uploadcare.com/api-refs/upload-api/#operation/fromURLUpload - # options: - # - check_URL_duplicates - # - filename - # - save_URL_duplicates - # - async - returns upload token instead of upload data - # - metadata - file metadata, hash - def upload_from_url(url, options = {}) - body = upload_from_url_body(url, options) - token_response = post(path: 'from_url/', headers: { 'Content-Type': body.content_type }, body: body) - return token_response if options[:async] - - uploaded_response = poll_upload_response(token_response.success[:token]) - return uploaded_response if uploaded_response.success[:status] == 'error' - - Dry::Monads::Result::Success.call(files: [uploaded_response.success]) - end - - # Check upload status - # - # @see https://uploadcare.com/api-refs/upload-api/#operation/fromURLUploadStatus - def get_upload_from_url_status(token) - query_params = { token: token } - get(path: 'from_url/status/', params: query_params) - end - - # Get information about an uploaded file - # Secret key not needed - # - # https://uploadcare.com/api-refs/upload-api/#tag/Upload/operation/fileUploadInfo - def file_info(uuid) - query_params = { - file_id: uuid, - pub_key: Uploadcare.config.public_key - } - get(path: 'info/', params: query_params) - end - - private - - alias api_struct_post post - def post(args = {}) - handle_throttling { api_struct_post(**args) } - end - - def poll_upload_response(token) - with_retries(max_tries: Uploadcare.config.max_request_tries, - base_sleep_seconds: Uploadcare.config.base_request_sleep, - max_sleep_seconds: Uploadcare.config.max_request_sleep, - rescue: RetryError) do - response = get_upload_from_url_status(token) - handle_polling_response(response) - end - end - - def handle_polling_response(response) - case response.success[:status] - when 'error' - raise RequestError, response.success[:error] - when 'progress', 'waiting', 'unknown' - raise RetryError, response.success[:error] || 'Upload is taking longer than expected. Try increasing the max_request_tries config if you know your file uploads will take more time.' # rubocop:disable Layout/LineLength - end - - response - end - - # Prepares body for upload_many method - def upload_many_body(arr, options = {}) - files_formdata = arr.to_h do |file| - [HTTP::FormData::File.new(file).filename, - form_data_for(file)] - end - HTTP::FormData::Multipart.new( - Param::Upload::UploadParamsGenerator.call(options).merge(files_formdata) - ) - end - - # Prepare upload_from_url initial request body - def upload_from_url_body(url, options = {}) - opts = { - 'pub_key' => Uploadcare.config.public_key, - 'source_url' => url, - 'store' => store_value(options[:store]) - } - opts.merge!(Param::Upload::SignatureGenerator.call) if Uploadcare.config.sign_uploads - HTTP::FormData::Multipart.new(options.merge(opts)) - end - - def store_value(store) - case store - when true, '1', 1 then '1' - when false, '0', 0 then '0' - else 'auto' - end - end - end - end -end diff --git a/lib/uploadcare/client/webhook_client.rb b/lib/uploadcare/client/webhook_client.rb deleted file mode 100644 index 4fa36b1a..00000000 --- a/lib/uploadcare/client/webhook_client.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require_relative 'rest_client' - -module Uploadcare - module Client - # client for webhook management - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook - class WebhookClient < RestClient - # Create webhook - # @see https://uploadcare.com/docs/api_reference/rest/webhooks/#subscribe - def create(options = {}) - body = { - target_url: options[:target_url], - event: options[:event] || 'file.uploaded', - is_active: options[:is_active].nil? ? true : options[:is_active] - }.merge( - { signing_secret: options[:signing_secret] }.compact - ).to_json - post(uri: '/webhooks/', content: body) - end - - # Returns array (not paginated list) of webhooks - # @see https://uploadcare.com/docs/api_reference/rest/webhooks/#get-list - def list - get(uri: '/webhooks/') - end - - # Permanently deletes subscription - # @see https://uploadcare.com/docs/api_reference/rest/webhooks/#unsubscribe - def delete(target_url) - body = { target_url: target_url }.to_json - request(method: 'DELETE', uri: '/webhooks/unsubscribe/', content: body) - end - - # Updates webhook - # @see https://uploadcare.com/docs/api_reference/rest/webhooks/#subscribe-update - def update(id, options = {}) - body = options.to_json - put(uri: "/webhooks/#{id}/", content: body) - end - - alias create_webhook create - alias list_webhooks list - alias delete_webhook delete - alias update_webhook update - end - end -end diff --git a/lib/uploadcare/clients/add_ons_client.rb b/lib/uploadcare/clients/add_ons_client.rb new file mode 100644 index 00000000..e616aacf --- /dev/null +++ b/lib/uploadcare/clients/add_ons_client.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Uploadcare + class AddOnsClient < RestClient + # Executes AWS Rekognition Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @return [Hash] The response containing the request ID + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionExecute + def aws_rekognition_detect_labels(uuid) + body = { target: uuid } + post('/addons/aws_rekognition_detect_labels/execute/', body) + end + + # Retrieves the execution status of an AWS Rekognition label detection Add-On. + # @param request_id [String] The unique request ID returned by the Add-On execution. + # @return [Hash] The response containing the current status of the label detection process. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionExecutionStatus + def aws_rekognition_detect_labels_status(request_id) + params = { request_id: request_id } + get('/addons/aws_rekognition_detect_labels/execute/status/', params) + end + + # Executes AWS Rekognition Moderation Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @return [Hash] The response containing the request ID + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecute + def aws_rekognition_detect_moderation_labels(uuid) + post('/addons/aws_rekognition_detect_moderation_labels/execute/', { target: uuid }) + end + + # Check AWS Rekognition Moderation execution status + # @param request_id [String] The Request ID from the Add-On execution + # @return [Hash] The response containing the status + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecutionStatus + def aws_rekognition_detect_moderation_labels_status(request_id) + get('/addons/aws_rekognition_detect_moderation_labels/execute/status/', { request_id: request_id }) + end + + # Executes ClamAV virus checking Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @param params [Hash] Optional parameters for the Add-On + # @return [Hash] The response containing the request ID + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/ucClamavVirusScanExecute + def uc_clamav_virus_scan(uuid, params = {}) + body = { target: uuid }.merge(params) + post('/addons/uc_clamav_virus_scan/execute/', body) + end + + # Checks the status of a ClamAV virus scan execution + # @param request_id [String] The Request ID from the Add-On execution + # @return [Hash] The response containing the status + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/ucClamavVirusScanExecutionStatus + def uc_clamav_virus_scan_status(request_id) + get('/addons/uc_clamav_virus_scan/execute/status/', { request_id: request_id }) + end + + # Executes remove.bg background image removal Add-On + # @param uuid [String] The UUID of the file to process + # @param params [Hash] Optional parameters for the Add-On execution + # @return [Hash] The response containing the request ID + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/removeBgExecute + def remove_bg(uuid, params = {}) + post('/addons/remove_bg/execute/', { target: uuid, params: params }) + end + + # Check Remove.bg execution status + # @param request_id [String] The Request ID from the Add-On execution + # @return [Hash] The response containing the status and result + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/removeBgExecutionStatus + def remove_bg_status(request_id) + get('/addons/remove_bg/execute/status/', { request_id: request_id }) + end + end +end diff --git a/lib/uploadcare/clients/document_converter_client.rb b/lib/uploadcare/clients/document_converter_client.rb new file mode 100644 index 00000000..af1d5a51 --- /dev/null +++ b/lib/uploadcare/clients/document_converter_client.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Uploadcare + class DocumentConverterClient < RestClient + # Fetches information about a document's format and possible conversion formats + # @param uuid [String] The UUID of the document + # @return [Hash] The response containing document information + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertInfo + def info(uuid) + get("/convert/document/#{uuid}/") + end + + # Converts a document to a specified format. + # @param paths [Array] Array of document UUIDs and target format + # @param options [Hash] Optional parameters like `store` and `save_in_group` + # @return [Hash] The response containing conversion details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvert + def convert_document(paths, options = {}) + body = { + paths: paths, + store: options[:store] ? '1' : '0', + save_in_group: options[:save_in_group] ? '1' : '0' + } + + post('/convert/document/', body) + end + + # Fetches the status of a document conversion job by token + # @param token [Integer] The job token + # @return [Hash] The response containing the job status + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertStatus + def status(token) + get("/convert/document/status/#{token}/") + end + end +end diff --git a/lib/uploadcare/clients/file_client.rb b/lib/uploadcare/clients/file_client.rb new file mode 100644 index 00000000..7246f212 --- /dev/null +++ b/lib/uploadcare/clients/file_client.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Uploadcare + class FileClient < RestClient + # Gets list of files without pagination fields + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesList + def list(params = {}) + get('files/', params) + end + + # Stores a file by UUID + # @param uuid [String] The UUID of the file to store + # @return [Hash] The response body containing the file details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesStore + def store(uuid) + put("/files/#{uuid}/storage/") + end + + # Deletes a file by UUID + # @param uuid [String] The UUID of the file to delete + # @return [Hash] The response body containing the deleted file details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/deleteFileStorage + def delete(uuid) + del("/files/#{uuid}/storage/") + end + + # Get file information by its UUID (immutable). + # @param uuid [String] The UUID of the file + # @return [Hash] The response body containing the file details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/fileInfo + def info(uuid, params = {}) + get("/files/#{uuid}/", params) + end + + # Batch store files by UUIDs + # @param uuids [Array] List of file UUIDs to store + # @return [Hash] The response body containing 'result' and 'problems' + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesStoring + def batch_store(uuids) + put('/files/storage/', uuids) + end + + # Batch delete files by UUIDs + # @param uuids [Array] List of file UUIDs to delete + # @return [Hash] The response body containing 'result' and 'problems' + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesDelete + def batch_delete(uuids) + del('/files/storage/', uuids) + end + + # Copies a file to local storage + # @param source [String] The CDN URL or UUID of the file to copy + # @param options [Hash] Optional parameters + # @option options [String] :store ('false') Whether to store the copied file ('true' or 'false') + # @option options [Hash] :metadata Arbitrary additional metadata + # @return [Hash] The response body containing 'type' and 'result' fields + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/createLocalCopy + def local_copy(source, options = {}) + params = { source: source }.merge(options) + post('/files/local_copy/', params) + end + + # Copies a file to remote storage + # @param source [String] The CDN URL or UUID of the file to copy + # @param target [String] The name of the custom storage + # @param options [Hash] Optional parameters + # @option options [Boolean] :make_public (true) Whether the copied file is public + # @option options [String] :pattern ('${default}') Pattern for the file name in the custom storage + # @return [Hash] The response body containing 'type' and 'result' fields + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/createRemoteCopy + def remote_copy(source, target, options = {}) + params = { source: source, target: target }.merge(options) + post('/files/remote_copy/', params) + end + end +end diff --git a/lib/uploadcare/clients/file_metadata_client.rb b/lib/uploadcare/clients/file_metadata_client.rb new file mode 100644 index 00000000..e69d8ee1 --- /dev/null +++ b/lib/uploadcare/clients/file_metadata_client.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Uploadcare + class FileMetadataClient < RestClient + # Retrieves all metadata associated with a specific file by UUID. + # @param uuid [String] The UUID of the file. + # @return [Hash] A hash containing all metadata key-value pairs for the file. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/_fileMetadata + def index(uuid) + get("/files/#{uuid}/metadata/") + end + + # Gets the value of a specific metadata key for a file by UUID + # @param uuid [String] The UUID of the file + # @param key [String] The metadata key + # @return [String] The value of the metadata key + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/fileMetadata + def show(uuid, key) + get("/files/#{uuid}/metadata/#{key}/") + end + + # Updates or creates a metadata key for a specific file by UUID + # @param uuid [String] The UUID of the file + # @param key [String] The key of the metadata + # @param value [String] The value of the metadata + # @return [String] The value of the updated or added metadata key + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/updateFileMetadataKey + def update(uuid, key, value) + put("/files/#{uuid}/metadata/#{key}/", value) + end + + # Deletes a specific metadata key for a file by UUID + # @param uuid [String] The UUID of the file + # @param key [String] The metadata key to delete + # @return [Nil] Returns nil on successful deletion + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/deleteFileMetadata + def delete(uuid, key) + del("/files/#{uuid}/metadata/#{key}/") + end + end +end diff --git a/lib/uploadcare/clients/group_client.rb b/lib/uploadcare/clients/group_client.rb new file mode 100644 index 00000000..5aee2177 --- /dev/null +++ b/lib/uploadcare/clients/group_client.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Uploadcare + class GroupClient < RestClient + # Fetches a paginated list of groups + # @param params [Hash] Optional query parameters for filtering, limit, ordering, etc. + # @return [Hash] The response containing the list of groups + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/groupsList + def list(params = {}) + get('/groups/', params) + end + + # Fetches group information by its UUID + # @param uuid [String] The UUID of the group (formatted as UUID~size) + # @return [Hash] The response containing the group's details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/groupInfo + def info(uuid) + get("/groups/#{uuid}/") + end + + # Deletes a group by its UUID + # @param uuid [String] The UUID of the group (formatted as UUID~size) + # @return [NilClass] Returns nil on successful deletion + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/deleteGroup + def delete(uuid) + del("/groups/#{uuid}/") + end + end +end diff --git a/lib/uploadcare/clients/multipart_upload_client.rb b/lib/uploadcare/clients/multipart_upload_client.rb new file mode 100644 index 00000000..6899f739 --- /dev/null +++ b/lib/uploadcare/clients/multipart_upload_client.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'net/http' + +module Uploadcare + class MultipartUploadClient < UploadClient + CHUNK_SIZE = 5 * 1024 * 1024 # 5MB chunks + + def start(filename, size, content_type = 'application/octet-stream', options = {}) + params = { + filename: filename, + size: size, + content_type: content_type, + UPLOADCARE_STORE: options[:store] || 'auto' + } + + options[:metadata]&.each do |key, value| + params["metadata[#{key}]"] = value + end + + execute_request(:post, '/multipart/start/', params) + end + + def upload_chunk(file_path, upload_data) + File.open(file_path, 'rb') do |file| + upload_data['parts'].each do |part| + file.seek(part['start_offset']) + chunk = file.read(part['end_offset'] - part['start_offset']) + + upload_part_to_s3(part['url'], chunk) + end + end + end + + def complete(uuid) + execute_request(:post, '/multipart/complete/', { uuid: uuid }) + end + + def upload_file(file_path, options = {}) + file_size = File.size(file_path) + filename = options[:filename] || File.basename(file_path) + + # Start multipart upload + upload_data = start(filename, file_size, 'application/octet-stream', options) + + # Upload chunks + upload_chunk(file_path, upload_data) + + # Complete upload + complete(upload_data['uuid']) + end + + private + + def upload_part_to_s3(presigned_url, chunk) + uri = URI(presigned_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + request = Net::HTTP::Put.new(uri) + request.body = chunk + request['Content-Type'] = 'application/octet-stream' + + response = http.request(request) + + return if response.is_a?(Net::HTTPSuccess) + + raise Uploadcare::RequestError, "Failed to upload chunk: #{response.code}" + end + end +end diff --git a/lib/uploadcare/clients/project_client.rb b/lib/uploadcare/clients/project_client.rb new file mode 100644 index 00000000..c032579b --- /dev/null +++ b/lib/uploadcare/clients/project_client.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Uploadcare + class ProjectClient < RestClient + # Fetches the current project information + # @return [Hash] The response containing the project details + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Project + def show + get('/project/') + end + end +end diff --git a/lib/uploadcare/clients/rest_client.rb b/lib/uploadcare/clients/rest_client.rb new file mode 100644 index 00000000..f0351b8e --- /dev/null +++ b/lib/uploadcare/clients/rest_client.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'uri' + +module Uploadcare + class RestClient + include Uploadcare::ErrorHandler + include Uploadcare::ThrottleHandler + attr_reader :config, :connection, :authenticator + + def initialize(config = Uploadcare.configuration) + @config = config + @connection = Faraday.new(url: config.rest_api_root) do |conn| + conn.request :json + conn.response :json, content_type: /\bjson$/ + conn.response :raise_error # Raises Faraday::Error on 4xx/5xx responses + end + @authenticator = Authenticator.new(config) + end + + def make_request(method, path, params = {}, headers = {}) + handle_throttling do + response = connection.public_send(method, path) do |req| + prepare_request(req, method, path, params, headers) + end + response.body + end + rescue Faraday::Error => e + handle_error(e) + end + + def post(path, params = {}, headers = {}) + make_request(:post, path, params, headers) + end + + def get(path, params = {}, headers = {}) + make_request(:get, path, params, headers) + end + + def put(path, params = {}, headers = {}) + make_request(:put, path, params, headers) + end + + def del(path, params = {}, headers = {}) + make_request(:delete, path, params, headers) + end + + private + + def prepare_request(req, method, path, params, headers) + upcase_method_name = method.to_s.upcase + uri = build_request_uri(path, params) + + prepare_headers(req, upcase_method_name, uri, headers) + prepare_body_or_params(req, upcase_method_name, params) + end + + def build_request_uri(path, params) + params.is_a?(Hash) ? build_uri(path, params) : path + end + + def prepare_headers(req, method, uri, headers) + req.headers.merge!(authenticator.headers(method, uri)) + req.headers.merge!(headers) + end + + def prepare_body_or_params(req, method, params) + if method == 'GET' + req.params.update(params) unless params.empty? + else + # Some APIs expect an empty JSON object {} instead of no body + req.body = params.empty? ? '{}' : params.to_json + end + end + + def build_uri(path, query_params = {}) + if query_params.empty? + path + else + "#{path}?#{URI.encode_www_form(query_params)}" + end + end + end +end diff --git a/lib/uploadcare/clients/upload_client.rb b/lib/uploadcare/clients/upload_client.rb new file mode 100644 index 00000000..b33a6c5b --- /dev/null +++ b/lib/uploadcare/clients/upload_client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Uploadcare + class UploadClient + BASE_URL = 'https://upload.uploadcare.com' + + def initialize(config = Uploadcare.configuration) + @config = config + end + + private + + attr_reader :config + + def connection + @connection ||= Faraday.new(url: BASE_URL) do |faraday| + faraday.request :multipart + faraday.request :url_encoded + faraday.response :json, content_type: /\bjson$/ + faraday.adapter Faraday.default_adapter + end + end + + def execute_request(method, uri, params = {}, headers = {}) + params[:pub_key] = config.public_key + headers['User-Agent'] = user_agent + + response = connection.send(method, uri, params, headers) + + handle_response(response) + rescue Faraday::Error => e + handle_faraday_error(e) + end + + def handle_response(response) + if response.success? + response.body + else + raise_upload_error(response) + end + end + + def handle_faraday_error(error) + message = error.message + raise Uploadcare::RequestError, "Request failed: #{message}" + end + + def raise_upload_error(response) + body = response.body + error_message = if body.is_a?(Hash) + body['error'] || body['detail'] || 'Upload failed' + else + "Upload failed with status #{response.status}" + end + + raise Uploadcare::RequestError.new(error_message, response.status) + end + + def user_agent + "Uploadcare Ruby/#{Uploadcare::VERSION} (Ruby/#{RUBY_VERSION})" + end + end +end diff --git a/lib/uploadcare/clients/uploader_client.rb b/lib/uploadcare/clients/uploader_client.rb new file mode 100644 index 00000000..63e4b02f --- /dev/null +++ b/lib/uploadcare/clients/uploader_client.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Uploadcare + class UploaderClient < UploadClient + def upload_file(file, options = {}) + File.open(file, 'rb') do |file_io| + params = build_upload_params(options) + params[:file] = Faraday::UploadIO.new(file_io, 'application/octet-stream') + + execute_request(:post, '/base/', params) + end + end + + def upload_files(files, options = {}) + results = files.map do |file| + upload_file(file, options) + end + + { files: results } + end + + def upload_from_url(url, options = {}) + params = build_upload_params(options) + params[:source_url] = url + + execute_request(:post, '/from_url/', params) + end + + def check_upload_status(token) + execute_request(:get, '/from_url/status/', { token: token }) + end + + def file_info(uuid) + execute_request(:get, '/info/', { file_id: uuid }) + end + + private + + def build_upload_params(options) + params = {} + + params[:store] = options[:store] if options.key?(:store) + params[:filename] = options[:filename] if options[:filename] + params[:check_URL_duplicates] = options[:check_duplicates] if options.key?(:check_duplicates) + params[:save_URL_duplicates] = options[:save_duplicates] if options.key?(:save_duplicates) + + options[:metadata]&.each do |key, value| + params["metadata[#{key}]"] = value + end + + params + end + end +end diff --git a/lib/uploadcare/clients/video_converter_client.rb b/lib/uploadcare/clients/video_converter_client.rb new file mode 100644 index 00000000..23bc009a --- /dev/null +++ b/lib/uploadcare/clients/video_converter_client.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Uploadcare + class VideoConverterClient < RestClient + # Converts a video file to the specified format + # @param paths [Array] An array of video UUIDs with conversion operations + # @param options [Hash] Optional parameters such as `store` + # @return [Hash] The response containing conversion results + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Video/operation/convertVideo + def convert_video(paths, options = {}) + params = { paths: paths }.merge(options) + post('/convert/video/', params) + end + + # Fetches the status of a video conversion job by token + # @param token [Integer] The job token + # @return [Hash] The response containing the job status + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/videoConvertStatus + def status(token) + get("/convert/video/status/#{token}/") + end + end +end diff --git a/lib/uploadcare/clients/webhook_client.rb b/lib/uploadcare/clients/webhook_client.rb new file mode 100644 index 00000000..2e3780d4 --- /dev/null +++ b/lib/uploadcare/clients/webhook_client.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Uploadcare + class WebhookClient < RestClient + # Fetches a list of project webhooks + # @return [Array] List of webhooks for the project + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhooksList + def list_webhooks + get('/webhooks/') + end + + # Create a new webhook + # @param target_url [String] The URL triggered by the webhook event + # @param event [String] The event to subscribe to (e.g., "file.uploaded") + # @param is_active [Boolean] Marks subscription as active or inactive + # @param signing_secret [String] HMAC/SHA-256 secret for securing webhook payloads + # @param version [String] Version of the webhook payload + # @return [Uploadcare::Webhook] The created webhook as an object + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhookCreate + def create_webhook(target_url, event, is_active, signing_secret, version) + payload = { + target_url: target_url, + event: event, + is_active: is_active, + signing_secret: signing_secret, + version: version + } + + post('/webhooks/', payload) + end + + # Update a webhook + # @param id [Integer] The ID of the webhook to update + # @param target_url [String] The new target URL + # @param event [String] The new event type + # @param is_active [Boolean] Whether the webhook is active + # @param signing_secret [String] Optional signing secret for the webhook + # @return [Hash] The updated webhook attributes + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/updateWebhook + def update_webhook(id, target_url, event, is_active: true, signing_secret: nil) + payload = { + target_url: target_url, + event: event, + is_active: is_active, + signing_secret: signing_secret + } + + put("/webhooks/#{id}/", payload) + end + + # Delete a webhook + # @param target_url [String] The target URL of the webhook to delete + # @return [Nil] Returns nil on successful deletion of the webhook. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhookUnsubscribe + def delete_webhook(target_url) + del('/webhooks/unsubscribe/', target_url) + end + end +end diff --git a/lib/uploadcare/cname_generator.rb b/lib/uploadcare/cname_generator.rb new file mode 100644 index 00000000..03081eb2 --- /dev/null +++ b/lib/uploadcare/cname_generator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'digest' +require 'uri' + +module Uploadcare + class CnameGenerator + class << self + def generate(public_key) + return nil unless public_key + + hash = Digest::SHA256.hexdigest(public_key) + hash.to_i(16).to_s(36)[0, 10] + end + + def cdn_base_url(public_key, cdn_base_postfix) + subdomain = generate(public_key) + return cdn_base_postfix unless subdomain + + uri = URI.parse(cdn_base_postfix) + uri.host = "#{subdomain}.#{uri.host}" + uri.to_s + end + end + end +end diff --git a/lib/uploadcare/concern/error_handler.rb b/lib/uploadcare/concern/error_handler.rb deleted file mode 100644 index 54236b60..00000000 --- a/lib/uploadcare/concern/error_handler.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Concerns - # Wrapper for responses - # raises errors instead of returning monads - module ErrorHandler - include Exception - - # Extension of ApiStruct's failure method - # - # Raises errors instead of returning falsey objects - # @see https://github.com/rubygarage/api_struct/blob/master/lib/api_struct/client.rb#L55 - def failure(response) - catch_upload_errors(response) - parsed_response = JSON.parse(response.body.to_s) - raise RequestError, parsed_response['detail'] || parsed_response.map { |k, v| "#{k}: #{v}" }.join('; ') - rescue JSON::ParserError - raise RequestError, response.body.to_s - end - - # Extension of ApiStruct's wrap method - # - # Catches throttling errors and Upload API errors - # - # @see https://github.com/rubygarage/api_struct/blob/master/lib/api_struct/client.rb#L45 - def wrap(response) - raise_throttling_error(response) if response.status == 429 - return failure(response) if response.status >= 300 - - catch_upload_errors(response) - success(response) - end - - private - - # Raise ThrottleError. Also, tells in error when server will be ready for next request - def raise_throttling_error(response) - retry_after = (response.headers['Retry-After'].to_i + 1) || 11 - raise ThrottleError.new(retry_after), "Response throttled, retry #{retry_after} seconds later" - end - - # Upload API returns its errors with code 200, and stores its actual code and details within response message - # This methods detects that and raises apropriate error - def catch_upload_errors(response) - return unless response.code == 200 - - parsed_response = JSON.parse(response.body.to_s) - error = parsed_response['error'] if parsed_response.is_a?(Hash) - raise RequestError, error if error - end - end - end -end diff --git a/lib/uploadcare/concern/throttle_handler.rb b/lib/uploadcare/concern/throttle_handler.rb deleted file mode 100644 index 454cb89e..00000000 --- a/lib/uploadcare/concern/throttle_handler.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Concerns - # This module lets clients send request multiple times if request is throttled - module ThrottleHandler - # call given block. If ThrottleError is returned, it will wait and attempt again 4 more times - # @yield executable block (HTTP request that may be throttled) - def handle_throttling - (Uploadcare.config.max_throttle_attempts - 1).times do - # rubocop:disable Style/RedundantBegin - begin - return yield - rescue(Exception::ThrottleError) => e - wait_time = e.timeout - sleep(wait_time) - next - end - # rubocop:enable Style/RedundantBegin - end - yield - end - end - end -end diff --git a/lib/uploadcare/concern/upload_error_handler.rb b/lib/uploadcare/concern/upload_error_handler.rb deleted file mode 100644 index 2dc9ed2f..00000000 --- a/lib/uploadcare/concern/upload_error_handler.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Concerns - # Wrapper for responses - # raises errors instead of returning monads - module UploadErrorHandler - include Exception - - # Extension of ApiStruct's failure method - # - # Raises errors instead of returning falsey objects - # @see https://github.com/rubygarage/api_struct/blob/master/lib/api_struct/client.rb#L55 - def failure(response) - catch_throttling_error(response) - parsed_response = JSON.parse(response.body.to_s) - raise RequestError, parsed_response['detail'] - rescue JSON::ParserError - raise RequestError, response.status - end - - private - - def catch_throttling_error(response) - return unless response.code == 429 - - retry_after = (response.headers['Retry-After'].to_i + 1) || 11 - raise ThrottleError.new(retry_after), "Response throttled, retry #{retry_after} seconds later" - end - end - end -end diff --git a/lib/uploadcare/concerns/cacheable.rb b/lib/uploadcare/concerns/cacheable.rb new file mode 100644 index 00000000..3c090bb0 --- /dev/null +++ b/lib/uploadcare/concerns/cacheable.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Uploadcare + module Concerns + # Adds caching capabilities to resources + module Cacheable + extend ActiveSupport::Concern if defined?(ActiveSupport) + + included do + class_attribute :cache_store + class_attribute :cache_expires_in, default: 300 # 5 minutes default + end + + class_methods do + def cached_find(uuid, expires_in: nil) + return find(uuid) unless cache_enabled? + + cache_key = "uploadcare:#{name.underscore}:#{uuid}" + expires = expires_in || cache_expires_in + + cache_store.fetch(cache_key, expires_in: expires) do + find(uuid) + end + end + + def cache_enabled? + cache_store.present? + end + + def clear_cache(uuid = nil) + if uuid + cache_key = "uploadcare:#{name.underscore}:#{uuid}" + cache_store.delete(cache_key) + else + # Clear all cache for this resource type + cache_store.clear if cache_store.respond_to?(:clear) + end + end + end + + def cache_key + "uploadcare:#{self.class.name.underscore}:#{uuid || id}" + end + + def expire_cache + self.class.cache_store&.delete(cache_key) + end + + def cached_info(expires_in: nil) + return info unless self.class.cache_enabled? + + expires = expires_in || self.class.cache_expires_in + self.class.cache_store.fetch("#{cache_key}:info", expires_in: expires) do + info + end + end + end + end +end \ No newline at end of file diff --git a/lib/uploadcare/concerns/transformable.rb b/lib/uploadcare/concerns/transformable.rb new file mode 100644 index 00000000..32d01311 --- /dev/null +++ b/lib/uploadcare/concerns/transformable.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Uploadcare + module Concerns + # Adds transformation capabilities to resources + module Transformable + extend ActiveSupport::Concern if defined?(ActiveSupport) + + # Chain transformations fluently + def resize(width, height = nil) + add_transformation(:resize, "#{width}x#{height || width}") + self + end + + def crop(dimensions, alignment = 'center') + add_transformation(:crop, "#{dimensions}/#{alignment}") + self + end + + def quality(value) + add_transformation(:quality, value) + self + end + + def format(type) + add_transformation(:format, type) + self + end + + def grayscale + add_transformation(:grayscale, true) + self + end + + def blur(strength = nil) + add_transformation(:blur, strength) + self + end + + def rotate(angle) + add_transformation(:rotate, angle) + self + end + + def flip + add_transformation(:flip, true) + self + end + + def mirror + add_transformation(:mirror, true) + self + end + + def smart_resize(width, height = nil) + add_transformation(:smart_resize, "#{width}x#{height || width}") + self + end + + def preview(width = nil, height = nil) + add_transformation(:preview, "#{width}x#{height}") if width + self + end + + def build_url + base_url = original_file_url || cdn_url + return base_url if @transformations.blank? + + transformations = @transformations.map do |key, value| + next if value.nil? || value == false + value == true ? "-/#{key}/" : "-/#{key}/#{value}/" + end.compact.join + + "#{base_url}#{transformations}" + end + + def to_url + build_url + end + + private + + def add_transformation(key, value) + @transformations ||= {} + @transformations[key] = value + self + end + end + end +end \ No newline at end of file diff --git a/lib/uploadcare/configuration.rb b/lib/uploadcare/configuration.rb new file mode 100644 index 00000000..9281cf09 --- /dev/null +++ b/lib/uploadcare/configuration.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Uploadcare + class Configuration + attr_accessor :public_key, :secret_key, :auth_type, :multipart_size_threshold, :rest_api_root, + :upload_api_root, :max_request_tries, :base_request_sleep, :max_request_sleep, :sign_uploads, + :upload_signature_lifetime, :max_throttle_attempts, :upload_threads, :framework_data, + :file_chunk_size, :logger, :cdn_base, :use_subdomains, :cdn_base_postfix + + # Adding Default constants instead of initialization to + # prevent AssignmentBranchSize violation + DEFAULTS = { + public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY', ''), + secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY', ''), + auth_type: 'Uploadcare', + multipart_size_threshold: 100 * 1024 * 1024, + rest_api_root: 'https://api.uploadcare.com', + upload_api_root: 'https://upload.uploadcare.com', + max_request_tries: 100, + base_request_sleep: 1, # seconds + max_request_sleep: 60.0, # seconds + sign_uploads: false, + upload_signature_lifetime: 30 * 60, # seconds + max_throttle_attempts: 5, + upload_threads: 2, # used for multiupload only ATM + framework_data: '', + file_chunk_size: 100, + logger: ENV['UPLOADCARE_DISABLE_LOGGING'] ? nil : Logger.new($stdout), + cdn_base: ENV.fetch('UPLOADCARE_CDN_BASE', 'https://ucarecdn.com/'), + use_subdomains: false, + cdn_base_postfix: ENV.fetch('UPLOADCARE_CDN_BASE_POSTFIX', 'https://ucarecd.net/') + }.freeze + + def initialize(options = {}) + DEFAULTS.merge(options).each do |attribute, value| + send("#{attribute}=", value) + end + end + + def cdn_url_base + return cdn_base unless use_subdomains && public_key && !public_key.empty? + + CnameGenerator.cdn_base_url(public_key, cdn_base_postfix) + end + end +end diff --git a/lib/uploadcare/entity/addons.rb b/lib/uploadcare/entity/addons.rb deleted file mode 100644 index dc74938b..00000000 --- a/lib/uploadcare/entity/addons.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer is responsible for addons handling - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons - class Addons < Entity - client_service AddonsClient - - attr_entity :request_id, :status, :result - end - end -end diff --git a/lib/uploadcare/entity/conversion/base_converter.rb b/lib/uploadcare/entity/conversion/base_converter.rb deleted file mode 100644 index 7a016f86..00000000 --- a/lib/uploadcare/entity/conversion/base_converter.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - module Conversion - # This serializer lets a user convert uploaded documents - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/documentConvert - class BaseConverter < Entity - class << self - # Converts files - # - # @param params [Array] of hashes with params or [Hash] - # @option options [Boolean] :store whether to store file on servers. - def convert(params, options = {}) - files_params = params.is_a?(Hash) ? [params] : params - conversion_client.new.convert_many(files_params, options) - end - - # Returns a status of a conversion job - # - # @param token [Integer, String] token obtained from a server in convert method - def status(token) - conversion_client.new.get_conversion_status(token) - end - - # Returns the document format and possible conversion formats. - # - # @param uuid [String] UUID of the document - def info(uuid) - conversion_client.new.document_info(uuid) - end - - private - - def conversion_client - clients[:base] - end - end - end - end - end - include Conversion -end diff --git a/lib/uploadcare/entity/conversion/document_converter.rb b/lib/uploadcare/entity/conversion/document_converter.rb deleted file mode 100644 index 2e63f851..00000000 --- a/lib/uploadcare/entity/conversion/document_converter.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative 'base_converter' - -module Uploadcare - module Entity - module Conversion - # This serializer lets a user convert uploaded documents - # @see https://uploadcare.com/api-refs/rest-api/v0.5.0/#operation/documentConvert - class DocumentConverter < BaseConverter - client_service Client::Conversion::DocumentConversionClient - end - end - end -end diff --git a/lib/uploadcare/entity/conversion/video_converter.rb b/lib/uploadcare/entity/conversion/video_converter.rb deleted file mode 100644 index c9c0c6a7..00000000 --- a/lib/uploadcare/entity/conversion/video_converter.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative 'base_converter' - -module Uploadcare - module Entity - module Conversion - # This serializer lets a user convert uploaded videos, and usually returns an array of results - # @see https://uploadcare.com/api-refs/rest-api/v0.5.0/#operation/videoConvert - class VideoConverter < BaseConverter - client_service Client::Conversion::VideoConversionClient - end - end - end -end diff --git a/lib/uploadcare/entity/decorator/paginator.rb b/lib/uploadcare/entity/decorator/paginator.rb deleted file mode 100644 index e26f674a..00000000 --- a/lib/uploadcare/entity/decorator/paginator.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # @abstract - module Decorator - # provides pagination methods for things in Uploadcare that paginate, - # namely [FileList] and [Group] - # - # Requirements: - # - Should be Entity with Client - # - Associated Client should have `list` method that returns objects with pagination - # - Response should have :next, :previous, :total, :per_page params and :results fields - module Paginator - @entity ||= Hashie::Mash.new - - # meta data of a pagination object - def meta - Hashie::Mash.new(next: @entity[:next], previous: @entity[:previous], - total: @entity[:total], per_page: @entity[:per_page]) - end - - # Returns new instance of current object on next page - def next_page - url = @entity[:next] - return unless url - - query = URI.decode_www_form(URI(url).query).to_h - query = query.to_h { |k, v| [k.to_sym, v] } - self.class.list(**query) - end - - # Returns new instance of current object on previous page - def previous_page - url = @entity[:previous] - return unless url - - query = URI.decode_www_form(URI(url).query).to_h - query = query.to_h { |k, v| [k.to_sym, v] } - self.class.list(**query) - end - - # Attempts to load the entire list after offset into results of current object - # - # It's possible to avoid loading objects on previous pages by offsetting them first - def load - return self if @entity[:next].nil? || @entity[:results].length == @entity[:total] - - np = self - until np.next.nil? - np = np.next_page - @entity[:results].concat(np.results.map(&:to_h)) - end - @entity[:next] = nil - @entity[:per_page] = @entity[:total] - self - end - - # iterate through pages, starting with current one - # - # @yield [Block] - def each(&block) - current_page = self - while current_page - current_page.results.each(&block) - current_page = current_page.next_page - end - end - - # Load and return all objects in list - # - # @return [Array] - def all - load[:results] - end - end - end - end -end diff --git a/lib/uploadcare/entity/entity.rb b/lib/uploadcare/entity/entity.rb deleted file mode 100644 index a957a6a0..00000000 --- a/lib/uploadcare/entity/entity.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -Gem.find_files('client/**/*.rb').each { |path| require path } - -module Uploadcare - # Entities represent objects existing in Uploadcare cloud - # - # Typically, Entities inherit class methods from {Client} instance methods - # @see Client - module Entity - # @abstract - class Entity < ApiStruct::Entity - include Client - end - end - - include Entity -end diff --git a/lib/uploadcare/entity/file.rb b/lib/uploadcare/entity/file.rb deleted file mode 100644 index 7750f810..00000000 --- a/lib/uploadcare/entity/file.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer returns a single file - # - # @see https://uploadcare.com/docs/api_reference/rest/handling_projects/ - class File < Entity - RESPONSE_PARAMS = %i[ - datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url - original_filename size url uuid variations content_info metadata appdata source - ].freeze - - client_service FileClient - - attr_entity(*RESPONSE_PARAMS) - - def datetime_stored - Uploadcare.config.logger&.warn 'datetime_stored property has been deprecated, and will be removed without a replacement in future.' # rubocop:disable Layout/LineLength - @entity.datetime_stored - end - - # gets file's uuid - even if it's only initialized with url - # @returns [String] - def uuid - return @entity.uuid if @entity.uuid - - uuid = @entity.url.gsub('https://ucarecdn.com/', '') - uuid.gsub(%r{/.*}, '') - end - - # loads file metadata, if it's initialized with url or uuid - def load - initialize(File.info(uuid).entity) - end - - # The method to convert a document file to another file - # gets (conversion) params [Hash], options (store: Boolean) [Hash], converter [Class] - # @returns [File] - def convert_document(params = {}, options = {}, converter = Conversion::DocumentConverter) - convert_file(params, converter, options) - end - - # The method to convert a video file to another file - # gets (conversion) params [Hash], options (store: Boolean) [Hash], converter [Class] - # @returns [File] - def convert_video(params = {}, options = {}, converter = Conversion::VideoConverter) - convert_file(params, converter, options) - end - - # Copies file to current project - # - # source can be UID or full CDN link - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/createLocalCopy - def self.local_copy(source, args = {}) - response = FileClient.new.local_copy(source: source, **args).success[:result] - File.new(response) - end - - # copy file to different project - # - # source can be UID or full CDN link - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/createRemoteCopy - def self.remote_copy(source, target, args = {}) - FileClient.new.remote_copy(source: source, target: target, **args).success[:result] - end - - # Instance version of {internal_copy} - def local_copy(args = {}) - File.local_copy(uuid, **args) - end - - # Instance version of {external_copy} - def remote_copy(target, args = {}) - File.remote_copy(uuid, target, **args) - end - - # Store a single file, preventing it from being deleted in 2 weeks - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/storeFile - def store - File.store(uuid) - end - - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#operation/deleteFileStorage - def delete - File.delete(uuid) - end - - private - - def convert_file(params, converter, options = {}) - raise Uploadcare::Exception::ConversionError, 'The first argument must be a Hash' unless params.is_a?(Hash) - - params_with_symbolized_keys = params.to_h { |k, v| [k.to_sym, v] } - params_with_symbolized_keys[:uuid] = uuid - result = converter.convert(params_with_symbolized_keys, options) - result.success? ? File.info(result.value![:result].first[:uuid]) : result - end - end - end -end diff --git a/lib/uploadcare/entity/file_list.rb b/lib/uploadcare/entity/file_list.rb deleted file mode 100644 index 509e9664..00000000 --- a/lib/uploadcare/entity/file_list.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'uploadcare/entity/file' -require 'uploadcare/entity/decorator/paginator' -require 'dry/monads' -require 'api_struct' - -module Uploadcare - module Entity - # This serializer returns lists of files - # - # This is a paginated list, so all pagination methods apply - # @see Uploadcare::Entity::Decorator::Paginator - class FileList < ApiStruct::Entity - include Uploadcare::Entity::Decorator::Paginator - client_service Client::FileListClient - - attr_entity :next, :previous, :total, :per_page - - has_entities :results, as: Uploadcare::Entity::File - has_entities :result, as: Uploadcare::Entity::File - - # alias for result/results, depending on which API this FileList was initialized from - # @return [Array] of [Uploadcare::Entity::File] - def files - results - rescue ApiStruct::EntityError - result - end - end - end -end diff --git a/lib/uploadcare/entity/file_metadata.rb b/lib/uploadcare/entity/file_metadata.rb deleted file mode 100644 index 44284ce4..00000000 --- a/lib/uploadcare/entity/file_metadata.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer is responsible for file metadata handling - # - # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata - class FileMetadata < Entity - client_service FileMetadataClient - - class << self - def index(uuid) - ::Uploadcare::Client::FileMetadataClient.new.index(uuid).success - end - - def show(uuid, key) - ::Uploadcare::Client::FileMetadataClient.new.show(uuid, key).success - end - - def update(uuid, key, value) - ::Uploadcare::Client::FileMetadataClient.new.update(uuid, key, value).success - end - - def delete(uuid, key) - ::Uploadcare::Client::FileMetadataClient.new.delete(uuid, key).success || '200 OK' - end - end - end - end -end diff --git a/lib/uploadcare/entity/group.rb b/lib/uploadcare/entity/group.rb deleted file mode 100644 index 0363d621..00000000 --- a/lib/uploadcare/entity/group.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'uploadcare/entity/file' - -module Uploadcare - module Entity - # Groups serve a purpose of better organizing files in your Uploadcare projects. - # - # You can create one from a set of files by using their UUIDs. - # - # @see https://uploadcare.com/docs/api_reference/upload/groups/ - class Group < Entity - client_service RestGroupClient, prefix: 'rest', only: %i[store info delete] - client_service GroupClient - - attr_entity :id, :datetime_created, :datetime_stored, :files_count, :cdn_url, :url - has_entities :files, as: Uploadcare::Entity::File - - # Remove these lines and bump api_struct version when this PR is accepted: - # @see https://github.com/rubygarage/api_struct/pull/15 - def self.store(uuid) - rest_store(uuid).success || '200 OK' - end - - # Get a file group by its ID. - def self.group_info(uuid) - rest_info(uuid) - end - - def self.delete(uuid) - rest_delete(uuid).success || '200 OK' - end - - # gets groups's id - even if it's only initialized with cdn_url - # @return [String] - def id - return @entity.id if @entity.id - - id = @entity.cdn_url.gsub('https://ucarecdn.com/', '') - id.gsub(%r{/.*}, '') - end - - # loads group metadata, if it's initialized with url or id - def load - initialize(Group.info(id).entity) - end - end - end -end diff --git a/lib/uploadcare/entity/group_list.rb b/lib/uploadcare/entity/group_list.rb deleted file mode 100644 index ad46fcf1..00000000 --- a/lib/uploadcare/entity/group_list.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'uploadcare/entity/group' -require 'uploadcare/entity/decorator/paginator' - -module Uploadcare - module Entity - # List of groups - # - # @see https://uploadcare.com/docs/api_reference/upload/groups/ - # - # This is a paginated list, so all pagination methods apply - # @see Uploadcare::Entity::Decorator::Paginator - class GroupList < Entity - include Uploadcare::Entity::Decorator::Paginator - client_service RestGroupClient, only: :list - - attr_entity :next, :previous, :total, :per_page, :results - has_entities :results, as: Group - - alias groups results - end - end -end diff --git a/lib/uploadcare/entity/project.rb b/lib/uploadcare/entity/project.rb deleted file mode 100644 index 596303a2..00000000 --- a/lib/uploadcare/entity/project.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer returns info about a project and its data - # @see https://uploadcare.com/docs/api_reference/rest/handling_projects/ - class Project < Entity - client_service ProjectClient - - attr_entity :collaborators, :pub_key, :name, :autostore_enabled - end - end -end diff --git a/lib/uploadcare/entity/uploader.rb b/lib/uploadcare/entity/uploader.rb deleted file mode 100644 index 444f5ed9..00000000 --- a/lib/uploadcare/entity/uploader.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer lets user upload files by various means, and usually returns an array of files - # @see https://uploadcare.com/api-refs/upload-api/#tag/Upload - class Uploader < Entity - client_service UploaderClient - client_service MultipartUploaderClient, only: :upload, prefix: :multipart - - attr_entity :files - has_entities :files, as: Uploadcare::Entity::File - - # Upload file or group of files from array, File, or url - # - # @param object [Array], [String] or [File] - # @param [Hash] options options for upload - # @option options [Boolean] :store whether to store file on servers. - def self.upload(object, options = {}) - if big_file?(object) - multipart_upload(object, options) - elsif file?(object) - upload_file(object, options) - elsif object.is_a?(Array) - upload_files(object, options) - elsif object.is_a?(String) - upload_from_url(object, options) - else - raise ArgumentError, "Expected input to be a file/Array/URL, given: `#{object}`" - end - end - - # upload single file - def self.upload_file(file, options = {}) - response = UploaderClient.new.upload_many([file], options) - uuid = response.success.values.first - if Uploadcare.config.secret_key.nil? - Uploadcare::Entity::File.new(file_info(uuid).success) - else - # we can get more info about the uploaded file - Uploadcare::Entity::File.info(uuid) - end - end - - # upload multiple files - def self.upload_files(arr, options = {}) - response = UploaderClient.new.upload_many(arr, options) - response.success.map { |pair| Uploadcare::Entity::File.new(uuid: pair[1], original_filename: pair[0]) } - end - - # upload file of size above 10mb (involves multipart upload) - def self.multipart_upload(file, options = {}, &block) - response = MultipartUploaderClient.new.upload(file, options, &block) - Uploadcare::Entity::File.new(response.success) - end - - # upload files from url - # @param url [String] - def self.upload_from_url(url, options = {}) - response = UploaderClient.new.upload_from_url(url, options) - return response.success[:token] unless response.success[:files] - - response.success[:files].map { |file_data| Uploadcare::Entity::File.new(file_data) } - end - - # gets a status of upload from url - # @param url [String] - def self.get_upload_from_url_status(token) - UploaderClient.new.get_upload_from_url_status(token) - end - - # Get information about an uploaded file (without the secret key) - # @param uuid [String] - def self.file_info(uuid) - UploaderClient.new.file_info(uuid) - end - - class << self - private - - # check if object is a file - def file?(object) - object.respond_to?(:path) && ::File.exist?(object.path) - end - - # check if object needs to be uploaded using multipart upload - def big_file?(object) - file?(object) && object.size >= Uploadcare.config.multipart_size_threshold - end - end - end - end -end diff --git a/lib/uploadcare/entity/webhook.rb b/lib/uploadcare/entity/webhook.rb deleted file mode 100644 index 5e53a6ac..00000000 --- a/lib/uploadcare/entity/webhook.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Entity - # This serializer is responsible for webhook handling - # - # @see https://uploadcare.com/docs/api_reference/rest/webhooks/ - class Webhook < Entity - client_service WebhookClient - - attr_entity :id, :created, :updated, :event, :target_url, :project, :is_active - end - end -end diff --git a/lib/uploadcare/error_handler.rb b/lib/uploadcare/error_handler.rb new file mode 100644 index 00000000..3dee692c --- /dev/null +++ b/lib/uploadcare/error_handler.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Uploadcare + module ErrorHandler + # Catches failed API errors + # Raises errors instead of returning falsey objects + def handle_error(error) + response = error.response + catch_upload_errors(response) + + # Parse JSON body if it's a string + parsed_response = response.dup + if response[:body].is_a?(String) && !response[:body].empty? + begin + parsed_response[:body] = JSON.parse(response[:body]) + rescue JSON::ParserError + # Keep original body if JSON parsing fails + end + end + + # Use RequestError.from_response to create the appropriate error type + raise Uploadcare::RequestError.from_response(parsed_response) + end + + private + + # Upload API returns its errors with code 200, and stores its actual code and details within response message + # This methods detects that and raises apropriate error + def catch_upload_errors(response) + return unless response[:status] == 200 + + parsed_response = JSON.parse(response[:body].to_s) + error = parsed_response['error'] if parsed_response.is_a?(Hash) + raise Uploadcare::RequestError.new(error, response) if error + end + end +end diff --git a/lib/uploadcare/errors.rb b/lib/uploadcare/errors.rb new file mode 100644 index 00000000..31917635 --- /dev/null +++ b/lib/uploadcare/errors.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module Uploadcare + # Base error class for all Uploadcare errors + class Error < StandardError + attr_reader :response, :request + + def initialize(message = nil, response = nil, request = nil) + super(message) + @response = response + @request = request + end + + def status + @response&.dig(:status) + end + + def headers + @response&.dig(:headers) + end + + def body + @response&.dig(:body) + end + end + + # Client errors (4xx) + class ClientError < Error; end + + # Bad request error (400) + class BadRequestError < ClientError; end + + # Authentication error (401) + class AuthenticationError < ClientError; end + + # Forbidden error (403) + class ForbiddenError < ClientError; end + + # Not found error (404) + class NotFoundError < ClientError; end + + # Method not allowed error (405) + class MethodNotAllowedError < ClientError; end + + # Not acceptable error (406) + class NotAcceptableError < ClientError; end + + # Request timeout error (408) + class RequestTimeoutError < ClientError; end + + # Conflict error (409) + class ConflictError < ClientError; end + + # Gone error (410) + class GoneError < ClientError; end + + # Unprocessable entity error (422) + class UnprocessableEntityError < ClientError; end + + # Too many requests error (429) + class RateLimitError < ClientError + def retry_after + headers&.dig('retry-after')&.to_i + end + end + + # Server errors (5xx) + class ServerError < Error; end + + # Internal server error (500) + class InternalServerError < ServerError; end + + # Not implemented error (501) + class NotImplementedError < ServerError; end + + # Bad gateway error (502) + class BadGatewayError < ServerError; end + + # Service unavailable error (503) + class ServiceUnavailableError < ServerError; end + + # Gateway timeout error (504) + class GatewayTimeoutError < ServerError; end + + # Network errors + class NetworkError < Error; end + + # Connection failed error + class ConnectionFailedError < NetworkError; end + + # Timeout error + class TimeoutError < NetworkError; end + + # SSL error + class SSLError < NetworkError; end + + # Configuration errors + class ConfigurationError < Error; end + + # Invalid configuration error + class InvalidConfigurationError < ConfigurationError; end + + # Missing configuration error + class MissingConfigurationError < ConfigurationError; end + + # Request errors (already exists but enhancing) + class RequestError < Error + # Error mapping for HTTP status codes + STATUS_ERROR_MAP = { + 400 => BadRequestError, + 401 => AuthenticationError, + 403 => ForbiddenError, + 404 => NotFoundError, + 405 => MethodNotAllowedError, + 406 => NotAcceptableError, + 408 => RequestTimeoutError, + 409 => ConflictError, + 410 => GoneError, + 422 => UnprocessableEntityError, + 429 => RateLimitError, + 500 => InternalServerError, + 501 => NotImplementedError, + 502 => BadGatewayError, + 503 => ServiceUnavailableError, + 504 => GatewayTimeoutError + }.freeze + + def self.from_response(response, request = nil) + status = response[:status] + message = extract_message(response) + + error_class = STATUS_ERROR_MAP[status] || + case status + when 400..499 then ClientError + when 500..599 then ServerError + else Error + end + + error_class.new(message, response, request) + end + + def self.extract_message(response) + body = response[:body] + + return "HTTP #{response[:status]}" unless body + + case body + when Hash + body['error'] || body['detail'] || body['message'] || "HTTP #{response[:status]}" + when String + body.empty? ? "HTTP #{response[:status]}" : body + else + "HTTP #{response[:status]}" + end + end + end + + # Conversion errors (already exists but keeping for compatibility) + class ConversionError < Error; end + + # Throttle errors (already exists but keeping for compatibility) + class ThrottleError < RateLimitError; end + + # Auth errors (already exists but keeping for compatibility) + class AuthError < AuthenticationError; end + + # Retry errors (already exists but keeping for compatibility) + class RetryError < Error; end +end diff --git a/lib/uploadcare/middleware/base.rb b/lib/uploadcare/middleware/base.rb new file mode 100644 index 00000000..29835c73 --- /dev/null +++ b/lib/uploadcare/middleware/base.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Uploadcare + module Middleware + class Base + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + end + end + end +end diff --git a/lib/uploadcare/middleware/logger.rb b/lib/uploadcare/middleware/logger.rb new file mode 100644 index 00000000..e4c220f9 --- /dev/null +++ b/lib/uploadcare/middleware/logger.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'logger' + +require_relative 'base' + +module Uploadcare + module Middleware + class Logger < Base + def initialize(app, logger = nil) + super(app) + @logger = logger || ::Logger.new($stdout) + end + + def call(env) + started_at = Time.now + log_request(env) + + response = @app.call(env) + + duration = Time.now - started_at + log_response(env, response, duration) + + response + rescue StandardError => e + duration = Time.now - started_at + log_error(env, e, duration) + raise + end + + private + + def log_request(env) + @logger.info "[Uploadcare] Request: #{env[:method].upcase} #{env[:url]}" + @logger.debug "[Uploadcare] Headers: #{filter_headers(env[:request_headers])}" if env[:request_headers] + @logger.debug "[Uploadcare] Body: #{filter_body(env[:body])}" if env[:body] + end + + def log_response(_env, response, duration) + @logger.info "[Uploadcare] Response: #{response[:status]} (#{format_duration(duration)})" + @logger.debug "[Uploadcare] Response Headers: #{response[:headers]}" if response[:headers] + @logger.debug "[Uploadcare] Response Body: #{truncate(response[:body].to_s)}" if response[:body] + end + + def log_error(_env, error, duration) + @logger.error "[Uploadcare] Error: #{error.class} - #{error.message} (#{format_duration(duration)})" + @logger.error "[Uploadcare] Backtrace: #{error.backtrace.first(5).join("\n")}" + end + + def filter_headers(headers) + headers.transform_keys(&:downcase).tap do |h| + h['authorization'] = '[FILTERED]' if h['authorization'] + h['x-uc-auth-key'] = '[FILTERED]' if h['x-uc-auth-key'] + end + end + + def filter_body(body) + return body unless body.is_a?(Hash) + + body.dup.tap do |b| + b['secret_key'] = '[FILTERED]' if b['secret_key'] + b['pub_key'] = '[FILTERED]' if b['pub_key'] + end + end + + def truncate(string, length = 1000) + return string if string.length <= length + + "#{string[0...length]}... (truncated)" + end + + def format_duration(seconds) + "#{(seconds * 1000).round(2)}ms" + end + end + end +end diff --git a/lib/uploadcare/middleware/retry.rb b/lib/uploadcare/middleware/retry.rb new file mode 100644 index 00000000..d87a5f1a --- /dev/null +++ b/lib/uploadcare/middleware/retry.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Uploadcare + module Middleware + class Retry < Base + DEFAULT_RETRY_OPTIONS = { + max_retries: 3, + retry_statuses: [429, 502, 503, 504], + exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed], + methods: %i[get head options], + retry_if: nil, + backoff_factor: 2, + exceptions_to_retry: [] + }.freeze + + def initialize(app, options = {}) + super(app) + @options = DEFAULT_RETRY_OPTIONS.merge(options) + @logger = @options[:logger] + end + + def call(env) + retries = 0 + loop do + response = @app.call(env) + + if should_retry?(env, response, nil, retries) + retries += 1 + log_retry(env, response[:status], retries, "status code #{response[:status]}") + sleep(calculate_delay(retries, response)) + next + end + + return response + rescue StandardError => e + if should_retry?(env, nil, e, retries) + retries += 1 + log_retry(env, nil, retries, e.class.name) + sleep(calculate_delay(retries)) + next + end + raise + end + end + + private + + def should_retry?(env, response, error, retries) + return false if retries >= @options[:max_retries] + return false unless retryable_method?(env[:method]) + + if error + retryable_error?(error) + elsif response + retryable_status?(response[:status]) || custom_retry_logic?(env, response) + else + false + end + end + + def retryable_method?(method) + @options[:methods].include?(method.to_s.downcase.to_sym) + end + + def retryable_status?(status) + @options[:retry_statuses].include?(status) + end + + def retryable_error?(error) + @options[:exceptions].any? { |klass| error.is_a?(klass) } || + @options[:exceptions_to_retry].any? { |klass| error.is_a?(klass) } + end + + def custom_retry_logic?(env, response) + return false unless @options[:retry_if] + + @options[:retry_if].call(env, response) + end + + def calculate_delay(retries, response = nil) + delay = @options[:backoff_factor]**(retries - 1) + + # Check for Retry-After header + if response && response[:headers] && response[:headers]['retry-after'] + retry_after = response[:headers]['retry-after'].to_i + delay = retry_after if retry_after.positive? + end + + # Add jitter to prevent thundering herd + delay + (rand * 0.3 * delay) + end + + def log_retry(env, _status, retries, reason) + return unless @logger + + message = "[Uploadcare] Retrying #{env[:method].upcase} #{env[:url]}" + message += " (attempt #{retries}/#{@options[:max_retries]})" + message += " after #{reason}" + + @logger.warn(message) + end + end + end +end diff --git a/lib/uploadcare/param/authentication_header.rb b/lib/uploadcare/param/authentication_header.rb deleted file mode 100644 index bfbf5514..00000000 --- a/lib/uploadcare/param/authentication_header.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'digest/md5' -require 'param/secure_auth_header' -require 'param/simple_auth_header' - -module Uploadcare - module Param - # This object returns headers needed for authentication - # This authentication method is more secure, but more tedious - class AuthenticationHeader - # @see https://uploadcare.com/docs/api_reference/rest/requests_auth/#auth-uploadcare - def self.call(options = {}) - validate_auth_config - case Uploadcare.config.auth_type - when 'Uploadcare' - SecureAuthHeader.call(options) - when 'Uploadcare.Simple' - SimpleAuthHeader.call - else - raise ArgumentError, "Unknown auth_scheme: '#{Uploadcare.config.auth_type}'" - end - end - - def self.validate_auth_config - raise Uploadcare::Exception::AuthError, 'Public Key is blank.' if is_blank?(Uploadcare.config.public_key) - raise Uploadcare::Exception::AuthError, 'Secret Key is blank.' if is_blank?(Uploadcare.config.secret_key) - end - - # rubocop:disable Naming/PredicateName - def self.is_blank?(value) - value.nil? || value.empty? - end - # rubocop:enable Naming/PredicateName - end - end -end diff --git a/lib/uploadcare/param/conversion/document/processing_job_url_builder.rb b/lib/uploadcare/param/conversion/document/processing_job_url_builder.rb deleted file mode 100644 index 923080a4..00000000 --- a/lib/uploadcare/param/conversion/document/processing_job_url_builder.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Param - module Conversion - module Document - class ProcessingJobUrlBuilder - class << self - def call(uuid:, format: nil, page: nil) - [ - uuid_part(uuid), - format_part(format), - page_part(page) - ].compact.join('-') - end - - private - - def uuid_part(uuid) - "#{uuid}/document/" - end - - def format_part(format) - return if format.nil? - - "/format/#{format}/" - end - - def page_part(page) - return if page.nil? - - "/page/#{page}/" - end - end - end - end - end - end -end diff --git a/lib/uploadcare/param/conversion/video/processing_job_url_builder.rb b/lib/uploadcare/param/conversion/video/processing_job_url_builder.rb deleted file mode 100644 index cd03af44..00000000 --- a/lib/uploadcare/param/conversion/video/processing_job_url_builder.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Param - module Conversion - module Video - class ProcessingJobUrlBuilder - class << self - # rubocop:disable Metrics/ParameterLists - def call(uuid:, size: {}, quality: nil, format: nil, cut: {}, thumbs: {}) - [ - uuid_part(uuid), - size_part(size), - quality_part(quality), - format_part(format), - cut_part(cut), - thumbs_part(thumbs) - ].compact.join('-') - end - # rubocop:enable Metrics/ParameterLists - - private - - def uuid_part(uuid) - "#{uuid}/video/" - end - - def size_part(size) - return if size.empty? - - dimensions = "#{size[:width]}x#{size[:height]}" if size[:width] || size[:height] - resize_mode = (size[:resize_mode]).to_s - "/size/#{dimensions}/#{resize_mode}/".squeeze('/') - end - - def quality_part(quality) - return if quality.nil? - - "/quality/#{quality}/" - end - - def format_part(format) - return if format.nil? - - "/format/#{format}/" - end - - def cut_part(cut) - return if cut.empty? - - "/cut/#{cut[:start_time]}/#{cut[:length]}/" - end - - def thumbs_part(thumbs) - return if thumbs.empty? - - "/thumbs~#{thumbs[:N]}/#{thumbs[:number]}/".squeeze('/') - end - end - end - end - end - end -end diff --git a/lib/uploadcare/param/param.rb b/lib/uploadcare/param/param.rb deleted file mode 100644 index defe5ae0..00000000 --- a/lib/uploadcare/param/param.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - # @abstract - # This module is responsible for everything related to generation of request params - - # such as authentication headers, signatures and serialized uploads - module Param - end - include Param -end diff --git a/lib/uploadcare/param/secure_auth_header.rb b/lib/uploadcare/param/secure_auth_header.rb deleted file mode 100644 index fb5db9da..00000000 --- a/lib/uploadcare/param/secure_auth_header.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require 'digest/md5' -require 'addressable/uri' - -module Uploadcare - module Param - # This object returns headers needed for authentication - # This authentication method is more secure, but more tedious - class SecureAuthHeader - class << self - # @see https://uploadcare.com/docs/api_reference/rest/requests_auth/#auth-uploadcare - def call(options = {}) - @method = options[:method] - @body = options[:content] || '' - @content_type = options[:content_type] - @uri = make_uri(options) - - @date_for_header = timestamp - { - Date: @date_for_header, - Authorization: "Uploadcare #{Uploadcare.config.public_key}:#{signature}" - } - end - - def signature - content_md5 = Digest::MD5.hexdigest(@body) - sign_string = [@method, content_md5, @content_type, @date_for_header, @uri].join("\n") - digest = OpenSSL::Digest.new('sha1') - OpenSSL::HMAC.hexdigest(digest, Uploadcare.config.secret_key, sign_string) - end - - def timestamp - Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT') - end - - private - - def make_uri(options) - if options[:params] && !options[:params].empty? - uri = Addressable::URI.parse options[:uri] - uri.query_values = uri.query_values(Array).to_a.concat(options[:params].to_a) - uri.to_s - else - options[:uri] - end - end - end - end - end -end diff --git a/lib/uploadcare/param/simple_auth_header.rb b/lib/uploadcare/param/simple_auth_header.rb deleted file mode 100644 index f72bad2b..00000000 --- a/lib/uploadcare/param/simple_auth_header.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Uploadcare - module Param - # This object returns simple header for authentication - # Simple header is relatively unsafe, but can be useful for debug and development - class SimpleAuthHeader - # @see https://uploadcare.com/docs/api_reference/rest/requests_auth/#auth-simple - def self.call - { Authorization: "Uploadcare.Simple #{Uploadcare.config.public_key}:#{Uploadcare.config.secret_key}" } - end - end - end -end diff --git a/lib/uploadcare/param/upload/signature_generator.rb b/lib/uploadcare/param/upload/signature_generator.rb deleted file mode 100644 index 23ff3fc5..00000000 --- a/lib/uploadcare/param/upload/signature_generator.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'digest' - -module Uploadcare - module Param - module Upload - # This class generates signatures for protected uploads - class SignatureGenerator - # @see https://uploadcare.com/docs/api_reference/upload/signed_uploads/ - # @return [Hash] signature and its expiration time - def self.call - expires_at = Time.now.to_i + Uploadcare.config.upload_signature_lifetime - to_sign = Uploadcare.config.secret_key + expires_at.to_s - signature = Digest::MD5.hexdigest(to_sign) - { - signature: signature, - expire: expires_at - } - end - end - end - end -end diff --git a/lib/uploadcare/param/upload/upload_params_generator.rb b/lib/uploadcare/param/upload/upload_params_generator.rb deleted file mode 100644 index 01a0128f..00000000 --- a/lib/uploadcare/param/upload/upload_params_generator.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require 'digest' - -module Uploadcare - module Param - module Upload - # This class generates body params for uploads - class UploadParamsGenerator - # @see https://uploadcare.com/docs/api_reference/upload/request_based/ - class << self - def call(options = {}) - { - 'UPLOADCARE_PUB_KEY' => Uploadcare.config.public_key, - 'UPLOADCARE_STORE' => store_value(options[:store]), - 'signature' => (Upload::SignatureGenerator.call if Uploadcare.config.sign_uploads) - }.merge(metadata(options)).compact - end - - private - - def store_value(store) - case store - when true, '1', 1 then '1' - when false, '0', 0 then '0' - else 'auto' - end - end - - def metadata(options = {}) - return {} if options[:metadata].nil? - - options[:metadata].each_with_object({}) do |(k, v), res| - res.merge!("metadata[#{k}]" => v) - end - end - end - end - end - end -end diff --git a/lib/uploadcare/param/user_agent.rb b/lib/uploadcare/param/user_agent.rb deleted file mode 100644 index 564c6066..00000000 --- a/lib/uploadcare/param/user_agent.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'uploadcare' - -module Uploadcare - module Param - # This header is added to track libraries using Uploadcare API - class UserAgent - # Generate header from Gem's config - # - # @example Uploadcare::Param::UserAgent.call - # UploadcareRuby/3.0.0-dev/Pubkey_(Ruby/2.6.3;UploadcareRuby) - def self.call - framework_data = Uploadcare.config.framework_data || '' - framework_data_string = "; #{Uploadcare.config.framework_data}" unless framework_data.empty? - public_key = Uploadcare.config.public_key - "UploadcareRuby/#{VERSION}/#{public_key} (Ruby/#{RUBY_VERSION}#{framework_data_string})" - end - end - end -end diff --git a/lib/uploadcare/param/webhook_signature_verifier.rb b/lib/uploadcare/param/webhook_signature_verifier.rb deleted file mode 100644 index 3ebdaf1d..00000000 --- a/lib/uploadcare/param/webhook_signature_verifier.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'digest/md5' - -module Uploadcare - module Param - # This object verifies a signature received along with webhook headers - class WebhookSignatureVerifier - # @see https://uploadcare.com/docs/security/secure-webhooks/ - def self.valid?(options = {}) - webhook_body_json = options[:webhook_body] - signing_secret = options[:signing_secret] || ENV.fetch('UC_SIGNING_SECRET', nil) - x_uc_signature_header = options[:x_uc_signature_header] - - digest = OpenSSL::Digest.new('sha256') - - calculated_signature = "v1=#{OpenSSL::HMAC.hexdigest(digest, signing_secret, webhook_body_json)}" - - calculated_signature == x_uc_signature_header - end - end - end -end diff --git a/lib/uploadcare/query.rb b/lib/uploadcare/query.rb new file mode 100644 index 00000000..71973533 --- /dev/null +++ b/lib/uploadcare/query.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +module Uploadcare + # Rails-style query interface for Uploadcare resources + class Query + include Enumerable + + attr_reader :resource_class, :params + + def initialize(resource_class, params = {}) + @resource_class = resource_class + @params = params + @executed = false + @results = nil + end + + # Chainable query methods + def where(conditions) + chain(conditions) + end + + def limit(value) + chain(limit: value) + end + + def offset(value) + chain(from: value) + end + + def order(field, direction = :asc) + ordering = direction == :desc ? "-#{field}" : field.to_s + chain(ordering: ordering) + end + + def stored(value = true) + chain(stored: value) + end + + def removed(value = false) + chain(removed: value) + end + + # Execution methods + def to_a + execute unless executed? + @results + end + + def each(&block) + to_a.each(&block) + end + + def first(n = nil) + if n + limit(n).to_a + else + limit(1).to_a.first + end + end + + def last(n = nil) + if n + order(:datetime_uploaded, :desc).limit(n).to_a + else + order(:datetime_uploaded, :desc).limit(1).to_a.first + end + end + + def count + execute unless executed? + @total_count || @results.size + end + + def exists? + !first.nil? + end + + def empty? + count == 0 + end + + def any?(&block) + if block_given? + to_a.any?(&block) + else + !empty? + end + end + + def all?(&block) + to_a.all?(&block) + end + + # Batch operations + def find_each(batch_size: 100) + return enum_for(:find_each, batch_size: batch_size) unless block_given? + + offset_value = nil + loop do + batch_query = offset_value ? offset(offset_value).limit(batch_size) : limit(batch_size) + batch = batch_query.to_a + + break if batch.empty? + + batch.each { |item| yield item } + + break if batch.size < batch_size + offset_value = batch.last.uuid + end + end + + def find_in_batches(batch_size: 100) + return enum_for(:find_in_batches, batch_size: batch_size) unless block_given? + + find_each(batch_size: batch_size).each_slice(batch_size) do |batch| + yield batch + end + end + + # Pagination + def page(number, per_page: 20) + offset_value = (number - 1) * per_page + limit(per_page).offset(offset_value) + end + + def next_page + return nil unless @next_url + self.class.new(resource_class, extract_params_from_url(@next_url)) + end + + def previous_page + return nil unless @previous_url + self.class.new(resource_class, extract_params_from_url(@previous_url)) + end + + # Pluck specific attributes + def pluck(*attributes) + to_a.map do |item| + if attributes.size == 1 + item.send(attributes.first) + else + attributes.map { |attr| item.send(attr) } + end + end + end + + def ids + pluck(:uuid) + end + + # Cache control + def cached(expires_in: 5.minutes) + @cache_expires_in = expires_in + self + end + + def fresh + @cache_expires_in = 0 + self + end + + private + + def chain(new_params) + self.class.new(resource_class, params.merge(new_params)) + end + + def execute + @executed = true + + if resource_class.respond_to?(:list) + result = resource_class.list(params) + + if result.respond_to?(:results) + @results = result.results + @total_count = result.total + @next_url = result.next + @previous_url = result.previous + else + @results = Array(result) + end + else + @results = [] + end + end + + def executed? + @executed + end + + def extract_params_from_url(url) + # Extract query parameters from URL + uri = URI.parse(url) + Rack::Utils.parse_nested_query(uri.query) + end + end + + # Module to add query interface to resources + module Queryable + extend ActiveSupport::Concern if defined?(ActiveSupport) + + class_methods do + def where(conditions) + Query.new(self, conditions) + end + + def limit(value) + Query.new(self).limit(value) + end + + def order(field, direction = :asc) + Query.new(self).order(field, direction) + end + + def stored(value = true) + Query.new(self).stored(value) + end + + def removed(value = false) + Query.new(self).removed(value) + end + + def all + Query.new(self) + end + + def first(n = nil) + Query.new(self).first(n) + end + + def last(n = nil) + Query.new(self).last(n) + end + + def find_each(**options, &block) + Query.new(self).find_each(**options, &block) + end + + def find_in_batches(**options, &block) + Query.new(self).find_in_batches(**options, &block) + end + + def exists?(**conditions) + where(conditions).exists? + end + + def count + Query.new(self).count + end + + def pluck(*attributes) + Query.new(self).pluck(*attributes) + end + end + end +end \ No newline at end of file diff --git a/lib/uploadcare/rails/active_record.rb b/lib/uploadcare/rails/active_record.rb new file mode 100644 index 00000000..d32f0f35 --- /dev/null +++ b/lib/uploadcare/rails/active_record.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module Uploadcare + module Rails + # ActiveRecord integration for Uploadcare files + module ActiveRecord + extend ActiveSupport::Concern + + class_methods do + # Define an Uploadcare file attribute + # @param attribute [Symbol] The attribute name + # @param options [Hash] Options for the attribute + # @option options [Boolean] :store (true) Whether to store files permanently + # @option options [Hash] :validations Validation rules for the file + def has_uploadcare_file(attribute, **options) + store_option = options.fetch(:store, true) + validations = options.fetch(:validations, {}) + + # UUID attribute getter/setter + define_method "#{attribute}_uuid" do + read_attribute("#{attribute}_uuid") + end + + define_method "#{attribute}_uuid=" do |value| + write_attribute("#{attribute}_uuid", value) + @uploadcare_files ||= {} + @uploadcare_files[attribute] = nil # Clear cached file + end + + # File object getter + define_method attribute do + uuid = send("#{attribute}_uuid") + return nil unless uuid.present? + + @uploadcare_files ||= {} + @uploadcare_files[attribute] ||= begin + file = Uploadcare::File.new(uuid: uuid) + file.store if store_option && !file.stored? + file + end + end + + # File object setter + define_method "#{attribute}=" do |value| + @uploadcare_files ||= {} + + case value + when Uploadcare::File + send("#{attribute}_uuid=", value.uuid) + @uploadcare_files[attribute] = value + when String + # Assume it's a UUID + send("#{attribute}_uuid=", value) + when Hash + # Upload from hash (e.g., from form) + if value[:file].present? + uploaded = Uploadcare::Uploader.upload(value[:file], store: store_option) + send("#{attribute}_uuid=", uploaded.uuid) + @uploadcare_files[attribute] = uploaded + end + when nil + send("#{attribute}_uuid=", nil) + @uploadcare_files[attribute] = nil + else + # Try to upload the object + uploaded = Uploadcare::Uploader.upload(value, store: store_option) + send("#{attribute}_uuid=", uploaded.uuid) + @uploadcare_files[attribute] = uploaded + end + end + + # URL helper + define_method "#{attribute}_url" do |transformations = nil| + file = send(attribute) + return nil unless file + + if transformations + file.build_url_with_transformations(transformations) + else + file.original_file_url + end + end + + # Add validations if specified + if validations.any? + validate do + file = send(attribute) + next unless file + + if validations[:size] && file.size > validations[:size] + errors.add(attribute, "file size exceeds #{validations[:size]} bytes") + end + + if validations[:mime_types] && !validations[:mime_types].include?(file.mime_type) + errors.add(attribute, "invalid file type") + end + end + end + end + + # Define multiple Uploadcare files (group) + def has_uploadcare_files(attribute, **options) + store_option = options.fetch(:store, true) + + # Group UUID getter/setter + define_method "#{attribute}_group_uuid" do + read_attribute("#{attribute}_group_uuid") + end + + define_method "#{attribute}_group_uuid=" do |value| + write_attribute("#{attribute}_group_uuid", value) + @uploadcare_groups ||= {} + @uploadcare_groups[attribute] = nil + end + + # Group getter + define_method attribute do + group_uuid = send("#{attribute}_group_uuid") + return [] unless group_uuid.present? + + @uploadcare_groups ||= {} + @uploadcare_groups[attribute] ||= begin + group = Uploadcare::Group.new(uuid: group_uuid) + group.store if store_option + group.files + end + end + + # Group setter + define_method "#{attribute}=" do |values| + @uploadcare_groups ||= {} + + case values + when Array + # Array of files or UUIDs + uuids = values.map do |v| + case v + when Uploadcare::File then v.uuid + when String then v + else + uploaded = Uploadcare::Uploader.upload(v, store: store_option) + uploaded.uuid + end + end + + group = Uploadcare::Group.create(uuids) + send("#{attribute}_group_uuid=", group.id) + @uploadcare_groups[attribute] = group.files + when Uploadcare::Group + send("#{attribute}_group_uuid=", values.id) + @uploadcare_groups[attribute] = values.files + when nil + send("#{attribute}_group_uuid=", nil) + @uploadcare_groups[attribute] = nil + end + end + end + end + + # Instance methods + def uploadcare_files + @uploadcare_files ||= {} + end + + def uploadcare_groups + @uploadcare_groups ||= {} + end + + def clear_uploadcare_cache + @uploadcare_files = {} + @uploadcare_groups = {} + end + end + end +end \ No newline at end of file diff --git a/lib/uploadcare/resources/add_ons.rb b/lib/uploadcare/resources/add_ons.rb new file mode 100644 index 00000000..e525c0a6 --- /dev/null +++ b/lib/uploadcare/resources/add_ons.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Uploadcare + class AddOns < BaseResource + attr_accessor :request_id, :status, :result + + class << self + # Executes AWS Rekognition Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @return [Uploadcare::AddOns] An instance of AddOns with the response data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionExecute + def aws_rekognition_detect_labels(uuid, config = Uploadcare.configuration) + response = add_ons_client(config).aws_rekognition_detect_labels(uuid) + new(response, config) + end + + # Check AWS Rekognition execution status + # @param request_id [String] The Request ID from the Add-On execution + # @return [Uploadcare::AddOns] An instance of AddOns with the status data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionExecutionStatus + def aws_rekognition_detect_labels_status(request_id, config = Uploadcare.configuration) + response = add_ons_client(config).aws_rekognition_detect_labels_status(request_id) + new(response, config) + end + + # Executes AWS Rekognition Moderation Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @return [Uploadcare::AddOns] An instance of AddOns with the response data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecute + def aws_rekognition_detect_moderation_labels(uuid, config = Uploadcare.configuration) + response = add_ons_client(config).aws_rekognition_detect_moderation_labels(uuid) + new(response, config) + end + + # Check AWS Rekognition Moderation execution status + # @param request_id [String] The Request ID from the Add-On execution + # @return [Uploadcare::AddOns] An instance of AddOns with the status data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/awsRekognitionDetectModerationLabelsExecutionStatus + def aws_rekognition_detect_moderation_labels_status(request_id, config = Uploadcare.configuration) + response = add_ons_client(config).aws_rekognition_detect_moderation_labels_status(request_id) + new(response, config) + end + + # Executes ClamAV virus checking Add-On + # @param uuid [String] The UUID of the file to process + # @param params [Hash] Optional parameters for the Add-On + # @return [Uploadcare::AddOns] An instance of AddOns with the response data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/ucClamavVirusScanExecute + def uc_clamav_virus_scan(uuid, params = {}, config = Uploadcare.configuration) + response = add_ons_client(config).uc_clamav_virus_scan(uuid, params) + new(response, config) + end + + # Checks the status of a ClamAV virus scan execution + # @param request_id [String] The Request ID from the Add-On execution + # @return [Uploadcare::AddOns] An instance of AddOns with the status data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/ucClamavVirusScanExecutionStatus + def uc_clamav_virus_scan_status(request_id, config = Uploadcare.configuration) + response = add_ons_client(config).uc_clamav_virus_scan_status(request_id) + new(response, config) + end + + # Executes remove.bg Add-On for a given target + # @param uuid [String] The UUID of the file to process + # @param params [Hash] Optional parameters for the Add-On execution + # @return [Uploadcare::AddOns] An instance of AddOns with the request ID + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/removeBgExecute + def remove_bg(uuid, params = {}, config = Uploadcare.configuration) + response = add_ons_client(config).remove_bg(uuid, params) + new(response, config) + end + + # Check Remove.bg Add-On execution status + # @param request_id [String] The Request ID from the Add-On execution + # @return [Uploadcare::AddOns] An instance of AddOns with the status and result + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons/operation/removeBgExecutionStatus + def remove_bg_status(request_id, config = Uploadcare.configuration) + response = add_ons_client(config).remove_bg_status(request_id) + new(response, config) + end + + private + + def add_ons_client(config) + @add_ons_client ||= Uploadcare::AddOnsClient.new(config) + end + end + end +end diff --git a/lib/uploadcare/resources/base_resource.rb b/lib/uploadcare/resources/base_resource.rb new file mode 100644 index 00000000..150cae21 --- /dev/null +++ b/lib/uploadcare/resources/base_resource.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Uploadcare + class BaseResource + attr_accessor :config + + def initialize(attributes = {}, config = Uploadcare.configuration) + @config = config + assign_attributes(attributes) + end + + protected + + def rest_client + @rest_client ||= Uploadcare::RestClient.new(@config) + end + + private + + def assign_attributes(attributes) + attributes.each do |key, value| + setter = "#{key}=" + send(setter, value) if respond_to?(setter) + end + end + end +end diff --git a/lib/uploadcare/resources/batch_file_result.rb b/lib/uploadcare/resources/batch_file_result.rb new file mode 100644 index 00000000..87fc3a05 --- /dev/null +++ b/lib/uploadcare/resources/batch_file_result.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Uploadcare + class BatchFileResult + attr_reader :status, :result, :problems + + def initialize(status:, result:, problems:, config:) + @status = status + @result = result.map { |file_data| File.new(file_data, config) } + @problems = problems + end + end +end diff --git a/lib/uploadcare/resources/document_converter.rb b/lib/uploadcare/resources/document_converter.rb new file mode 100644 index 00000000..9d0d5534 --- /dev/null +++ b/lib/uploadcare/resources/document_converter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Uploadcare + class DocumentConverter < BaseResource + attr_accessor :error, :format, :converted_groups, :status, :result + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + assign_attributes(attributes) + @config = config + end + + # Fetches information about a document's format and possible conversion formats + # @param uuid [String] The UUID of the document + # @return [Uploadcare::Document] An instance of Document with API response data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertInfo + def info(uuid) + response = document_client.info(uuid) + assign_attributes(response) + self + end + + # Converts a document to a specified format + # @param document_params [Hash] Contains UUIDs and target format + # @param options [Hash] Optional parameters such as `store` and `save_in_group` + # @return [Array] The response containing conversion results for each document + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvert + + def self.convert_document(document_params, options = {}, config = Uploadcare.configuration) + document_client = Uploadcare::DocumentConverterClient.new(config) + paths = Array(document_params[:uuid]).map do |uuid| + "#{uuid}/document/-/format/#{document_params[:format]}/" + end + + document_client.convert_document(paths, options) + end + + # Fetches document conversion job status by its token + # @param token [Integer] The job token + # @return [Uploadcare::DocumentConverter] An instance of DocumentConverter with status data + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/documentConvertStatus + + def fetch_status(token) + response = document_client.status(token) + assign_attributes(response) + self + end + + private + + def document_client + @document_client ||= Uploadcare::DocumentConverterClient.new(@config) + end + end +end diff --git a/lib/uploadcare/resources/file.rb b/lib/uploadcare/resources/file.rb new file mode 100644 index 00000000..3de625c7 --- /dev/null +++ b/lib/uploadcare/resources/file.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Uploadcare + class File < BaseResource + ATTRIBUTES = %i[ + datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url + original_filename size url uuid variations content_info metadata appdata source + ].freeze + + attr_accessor(*ATTRIBUTES) + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + @file_client = Uploadcare::FileClient.new(config) + end + + # This method returns a list of Files + # This is a paginated FileList, so all pagination methods apply + # @param options [Hash] Optional parameters + # @param config [Uploadcare::Configuration] Configuration object + # @return [Uploadcare::FileList] + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesList + def self.list(options = {}, config = Uploadcare.configuration) + file_client = Uploadcare::FileClient.new(config) + response = file_client.list(options) + + files = response['results'].map do |file_data| + new(file_data, config) + end + + PaginatedCollection.new( + resources: files, + next_page: response['next'], + previous_page: response['previous'], + per_page: response['per_page'], + total: response['total'], + client: file_client, + resource_class: self + ) + end + + # Stores the file, making it permanently available + # @return [Uploadcare::File] The updated File instance + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/storeFile + def store + response = @file_client.store(uuid) + + assign_attributes(response) + self + end + + # Removes individual files. Returns file info. + # @return [Uploadcare::File] The deleted File instance + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/deleteFileStorage + def delete + response = @file_client.delete(uuid) + + assign_attributes(response) + self + end + + # Get File information by its UUID (immutable) + # @return [Uploadcare::File] The File instance + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/fileinfo + def info(params = {}) + response = @file_client.info(uuid, params) + + assign_attributes(response) + self + end + + # Copies this file to local storage + # @param options [Hash] Optional parameters + # @return [Uploadcare::File] The copied file instance + def local_copy(options = {}) + response = @file_client.local_copy(uuid, options) + file_data = response['result'] + self.class.new(file_data, @config) + end + + # Copies this file to remote storage + # @param target [String] The name of the custom storage + # @param options [Hash] Optional parameters + # @return [String] The URL of the copied file in the remote storage + def remote_copy(target, options = {}) + response = @file_client.remote_copy(uuid, target, options) + response['result'] + end + + # Batch store files, making them permanently available + # @param uuids [Array] List of file UUIDs to store + # @param config [Uploadcare::Configuration] Configuration object + # @return [Uploadcare::BatchFileResult] + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesStoring + def self.batch_store(uuids, config = Uploadcare.configuration) + file_client = Uploadcare::FileClient.new(config) + response = file_client.batch_store(uuids) + + BatchFileResult.new( + status: response[:status], + result: response[:result], + problems: response[:problems] || {}, + config: config + ) + end + + # Batch delete files, removing them permanently + # @param uuids [Array] List of file UUIDs to delete + # @param config [Uploadcare::Configuration] Configuration object + # @return [Uploadcare::BatchFileResult] + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/filesDelete + def self.batch_delete(uuids, config = Uploadcare.configuration) + file_client = Uploadcare::FileClient.new(config) + response = file_client.batch_delete(uuids) + + BatchFileResult.new( + status: response[:status], + result: response[:result], + problems: response[:problems] || {}, + config: config + ) + end + + # Copies a file to local storage + # @param source [String] The CDN URL or UUID of the file to copy + # @param options [Hash] Optional parameters + # @param config [Uploadcare::Configuration] Configuration object + # @return [Uploadcare::File] The copied file + def self.local_copy(source, options = {}, config = Uploadcare.configuration) + file_client = Uploadcare::FileClient.new(config) + response = file_client.local_copy(source, options) + file_data = response['result'] + new(file_data, config) + end + + # Copies a file to remote storage + # @param source [String] The CDN URL or UUID of the file to copy + # @param target [String] The name of the custom storage + # @param options [Hash] Optional parameters + # @param config [Uploadcare::Configuration] Configuration object + # @return [String] The URL of the copied file in the remote storage + def self.remote_copy(source, target, options = {}, config = Uploadcare.configuration) + file_client = Uploadcare::FileClient.new(config) + response = file_client.remote_copy(source, target, options) + response['result'] + end + + # Get the CDN URL for this file + # @param transformations [String] Optional URL transformations + # @return [String] The CDN URL + def cdn_url(transformations = nil) + base_url = "#{@config.cdn_url_base}#{uuid}/" + transformations ? "#{base_url}-/#{transformations}/" : base_url + end + + # Create a URL builder for this file + # @return [Uploadcare::UrlBuilder] URL builder instance + def url_builder + UrlBuilder.new(self, @config) + end + end +end diff --git a/lib/uploadcare/resources/file_metadata.rb b/lib/uploadcare/resources/file_metadata.rb new file mode 100644 index 00000000..44c8dfd4 --- /dev/null +++ b/lib/uploadcare/resources/file_metadata.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Uploadcare + class FileMetadata < BaseResource + ATTRIBUTES = %i[ + datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url + original_filename size url uuid variations content_info metadata appdata source + ].freeze + + attr_accessor(*ATTRIBUTES) + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + @file_metadata_client = Uploadcare::FileMetadataClient.new(config) + end + + # Retrieves metadata for the file + # @return [Hash] The metadata keys and values for the file + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/_fileMetadata + # TODO - Remove uuid if the opeartion is being perfomed on same file + def index(uuid) + response = @file_metadata_client.index(uuid) + assign_attributes(response) + self + end + + # Updates metadata key's value + # @return [String] The updated value of the metadata key + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/updateFileMetadataKey + # TODO - Remove uuid if the opeartion is being perfomed on same file + def update(uuid, key, value) + @file_metadata_client.update(uuid, key, value) + end + + # Retrieves the value of a specific metadata key for the file + # @param key [String] The metadata key to retrieve + # @return [String] The value of the metadata key + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/fileMetadata + # TODO - Remove uuid if the opeartion is being perfomed on same file + def show(uuid, key) + @file_metadata_client.show(uuid, key) + end + + # Deletes a specific metadata key for the file + # @param key [String] The metadata key to delete + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-metadata/operation/deleteFileMetadata + # TODO - Remove uuid if the opeartion is being perfomed on same file + def delete(uuid, key) + @file_metadata_client.delete(uuid, key) + end + end +end diff --git a/lib/uploadcare/resources/group.rb b/lib/uploadcare/resources/group.rb new file mode 100644 index 00000000..61de065c --- /dev/null +++ b/lib/uploadcare/resources/group.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Uploadcare + class Group < BaseResource + ATTRIBUTES = %i[ + id datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url cdn_url + original_filename size url uuid variations content_info metadata appdata source datetime_created files_count files + ].freeze + + attr_accessor(*ATTRIBUTES) + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + @group_client = Uploadcare::GroupClient.new(config) + end + # Retrieves a paginated list of groups based on the provided parameters. + # @param params [Hash] Optional parameters for filtering and pagination. + # @param config [Uploadcare::Configuration] The Uploadcare configuration to use. + # @return [Uploadcare::PaginatedCollection] A collection of groups with pagination details. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/groupsList + + def self.list(params = {}, config = Uploadcare.configuration) + group_client = Uploadcare::GroupClient.new(config) + response = group_client.list(params) + groups = response['results'].map { |data| new(data, config) } + + PaginatedCollection.new( + resources: groups, + next_page: response['next'], + previous_page: response['previous'], + per_page: response['per_page'], + total: response['total'], + client: group_client, + resource_class: self + ) + end + + # Retrieves information about a specific group by UUID. + # @param uuid [String] The UUID of the group to retrieve. + # @return [Uploadcare::Group] The updated instance with group information. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/groupInfo + # TODO - Remove uuid if the opeartion is being perfomed on same file + + def info(uuid) + response = @group_client.info(uuid) + + assign_attributes(response) + self + end + # Deletes a group by UUID. + # @param uuid [String] The UUID of the group to delete. + # @return [Nil] Returns nil on successful deletion. + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Group/operation/deleteGroup + # TODO - Remove uuid if the opeartion is being perfomed on same file + + def delete(uuid) + @group_client.delete(uuid) + end + + # Create a new group from files + # @param files [Array] Array of file UUIDs + # @param config [Uploadcare::Configuration] Configuration object + # @return [Uploadcare::Group] The created group + def self.create(files, config = Uploadcare.configuration) + group_client = Uploadcare::GroupClient.new(config) + response = group_client.create(files) + new(response, config) + end + + # Get the CDN URL for this group + # @return [String] The CDN URL + def cdn_url + "#{@config.cdn_url_base}#{id}/" + end + + # Get CDN URLs for all files in the group + # @return [Array] Array of CDN URLs for each file + def file_cdn_urls + return [] unless files + + files.map { |index| "#{cdn_url}nth/#{index}/" } + end + end +end diff --git a/lib/uploadcare/resources/paginated_collection.rb b/lib/uploadcare/resources/paginated_collection.rb new file mode 100644 index 00000000..d298710f --- /dev/null +++ b/lib/uploadcare/resources/paginated_collection.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'uri' + +module Uploadcare + class PaginatedCollection + include Enumerable + attr_reader :resources, :next_page_url, :previous_page_url, :per_page, :total, :client, :resource_class + + def initialize(params = {}) + @resources = params[:resources] + @next_page_url = params[:next_page] + @previous_page_url = params[:previous_page] + @per_page = params[:per_page] + @total = params[:total] + @client = params[:client] + @resource_class = params[:resource_class] + end + + def each(&block) + @resources.each(&block) + end + + # Fetches the next page of resources + # Returns [nil] if next_page_url is nil + # @return [Uploadcare::FileList] + def next_page + fetch_page(@next_page_url) + end + + # Fetches the previous page of resources + # Returns [nil] if previous_page_url is nil + # @return [Uploadcare::FileList] + def previous_page + fetch_page(@previous_page_url) + end + + # Returns all resources from all pages + # @return [Array] Array of all resources across all pages + def all + all_resources = @resources.dup + current_page = self + + while current_page.next_page_url + current_page = current_page.next_page + all_resources.concat(current_page.resources) if current_page + end + + all_resources + end + + private + + def fetch_page(page_url) + return nil unless page_url + + params = extract_params_from_url(page_url) + response = fetch_response(params) + build_paginated_collection(response) + end + + def extract_params_from_url(page_url) + uri = URI.parse(page_url) + URI.decode_www_form(uri.query.to_s).to_h + end + + def fetch_response(params) + client.list(params) + end + + def build_paginated_collection(response) + new_resources = build_resources(response['results']) + + self.class.new( + resources: new_resources, + next_page: response['next'], + previous_page: response['previous'], + per_page: response['per_page'], + total: response['total'], + client: client, + resource_class: resource_class + ) + end + + def build_resources(results) + results.map { |resource_data| resource_class.new(resource_data, client.config) } + end + end +end diff --git a/lib/uploadcare/resources/project.rb b/lib/uploadcare/resources/project.rb new file mode 100644 index 00000000..7472d75b --- /dev/null +++ b/lib/uploadcare/resources/project.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Uploadcare + class Project < BaseResource + attr_accessor :name, :pub_key, :autostore_enabled, :collaborators + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + @project_client = Uploadcare::ProjectClient.new(config) + assign_attributes(attributes) + end + + # Fetches project information + # @return [Uploadcare::Project] The Project instance with populated attributes + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Project + def self.show(config = Uploadcare.configuration) + project_client = Uploadcare::ProjectClient.new(config) + response = project_client.show + new(response, config) + end + end +end diff --git a/lib/uploadcare/resources/uploader.rb b/lib/uploadcare/resources/uploader.rb new file mode 100644 index 00000000..050cd066 --- /dev/null +++ b/lib/uploadcare/resources/uploader.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Uploadcare + class Uploader < BaseResource + class << self + # Upload a file or array of files + # @param input [String, File, Array] File path, File object, or array of files + # @param options [Hash] Upload options + # @return [Uploadcare::File, Array] Uploaded file(s) + def upload(input, options = {}, config = Uploadcare.configuration) + case input + when Array + upload_files(input, options, config) + when String + if input.start_with?('http://', 'https://') + upload_from_url(input, options, config) + else + upload_file(input, options, config) + end + else + upload_file(input, options, config) + end + end + + # Upload a single file + # @param file [String, File] File path or File object + # @param options [Hash] Upload options + # @return [Uploadcare::File] Uploaded file + def upload_file(file, options = {}, config = Uploadcare.configuration) + uploader_client = UploaderClient.new(config) + + file_path = file.is_a?(String) ? file : file.path + file_size = File.size(file_path) + + response = if file_size > 10 * 1024 * 1024 # 10MB threshold for multipart + multipart_client = MultipartUploadClient.new(config) + multipart_client.upload_file(file_path, options) + else + uploader_client.upload_file(file_path, options) + end + + file_data = response['file'] || response + File.new(file_data, config) + end + + # Upload multiple files + # @param files [Array] Array of file paths or File objects + # @param options [Hash] Upload options + # @return [Array] Array of uploaded files + def upload_files(files, options = {}, config = Uploadcare.configuration) + # Use threads for parallel uploads, limited by upload_threads config + threads = [] + results = [] + mutex = Mutex.new + + files.each_slice(config.upload_threads || 2) do |file_batch| + file_batch.each do |file| + threads << Thread.new do + result = upload_file(file, options, config) + mutex.synchronize { results << result } + rescue StandardError => e + mutex.synchronize { results << e } + end + end + + # Wait for current batch to complete before starting next + threads.each(&:join) + threads.clear + end + + # Check for errors and raise if any occurred + errors = results.select { |r| r.is_a?(Exception) } + raise errors.first if errors.any? + + results + end + + # Upload a file from URL + # @param url [String] URL of the file to upload + # @param options [Hash] Upload options + # @return [Uploadcare::File] Uploaded file or token for async upload + def upload_from_url(url, options = {}, config = Uploadcare.configuration) + uploader_client = UploaderClient.new(config) + response = uploader_client.upload_from_url(url, options) + + if response['token'] + # Async upload, return token info + { + token: response['token'], + status: 'pending', + check_status: -> { check_upload_status(response['token'], config) } + } + else + # Sync upload completed + file_data = response['file'] || response + File.new(file_data, config) + end + end + + # Check status of async upload + # @param token [String] Upload token + # @return [Hash, Uploadcare::File] Status info or uploaded file + def check_upload_status(token, config = Uploadcare.configuration) + uploader_client = UploaderClient.new(config) + response = uploader_client.check_upload_status(token) + + case response['status'] + when 'success' + file_data = response['file'] || response['result'] + File.new(file_data, config) + when 'error' + raise Uploadcare::RequestError, response['error'] || 'Upload failed' + else + response + end + end + + # Get file info without storing + # @param uuid [String] File UUID + # @return [Hash] File information + def file_info(uuid, config = Uploadcare.configuration) + uploader_client = UploaderClient.new(config) + uploader_client.file_info(uuid) + end + end + end +end diff --git a/lib/uploadcare/resources/video_converter.rb b/lib/uploadcare/resources/video_converter.rb new file mode 100644 index 00000000..3bc8c609 --- /dev/null +++ b/lib/uploadcare/resources/video_converter.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Uploadcare + class VideoConverter < BaseResource + attr_accessor :problems, :status, :error, :result + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + @video_converter_client = Uploadcare::VideoConverterClient.new(config) + assign_attributes(attributes) + end + + # Converts a video to a specified format + # @param video_params [Hash] Contains UUIDs and target format, quality + # @param options [Hash] Optional parameters such as `store` + # @return [Array] The response containing conversion results for each video + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Video/operation/convertVideo + + def self.convert(video_params, options = {}, config = Uploadcare.configuration) + paths = Array(video_params[:uuid]).map do |uuid| + "#{uuid}/video/-/format/#{video_params[:format]}/-/quality/#{video_params[:quality]}/" + end + + video_converter_client = Uploadcare::VideoConverterClient.new(config) + video_converter_client.convert_video(paths, options) + end + + # Fetches the status of a video conversion job by token + # @param token [Integer] The job token + # @return [Hash] The response containing the job status + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Conversion/operation/videoConvertStatus + def fetch_status(token) + response = @video_converter_client.status(token) + assign_attributes(response) + self + end + end +end diff --git a/lib/uploadcare/resources/webhook.rb b/lib/uploadcare/resources/webhook.rb new file mode 100644 index 00000000..62373c4b --- /dev/null +++ b/lib/uploadcare/resources/webhook.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Uploadcare + class Webhook < BaseResource + attr_accessor :id, :project, :created, :updated, :event, :target_url, :is_active, :signing_secret, :version + + def initialize(attributes = {}, config = Uploadcare.configuration) + super + end + + # Class method to list all project webhooks + # @return [Array] Array of Webhook instances + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhooksList + def self.list(config = Uploadcare.configuration) + webhook_client = Uploadcare::WebhookClient.new(config) + response = webhook_client.list_webhooks + + response.map { |webhook_data| new(webhook_data, config) } + end + + # Create a new webhook + # @param target_url [String] The URL triggered by the webhook event + # @param event [String] The event to subscribe to + # @param is_active [Boolean] Marks subscription as active or inactive + # @param signing_secret [String] HMAC/SHA-256 secret for securing webhook payloads + # @param version [String] Version of the webhook payload + # @return [Uploadcare::Webhook] The created webhook as an object + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhookCreate + + def self.create(target_url, event, is_active: true, signing_secret: nil, version: '0.7') + client = Uploadcare::WebhookClient.new + response = client.create_webhook(target_url, event, is_active, signing_secret, version) + new(response) + end + + # Update a webhook + # @param id [Integer] The ID of the webhook to update + # @param target_url [String] The new target URL + # @param event [String] The new event type + # @param is_active [Boolean] Whether the webhook is active + # @param signing_secret [String] Optional signing secret for the webhook + # @return [Uploadcare::Webhook] The updated webhook as an object + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/updateWebhook + def self.update(id, target_url, event, is_active: true, signing_secret: nil) + client = Uploadcare::WebhookClient.new + response = client.update_webhook(id, target_url, event, is_active: is_active, signing_secret: signing_secret) + new(response) + end + + # Update this webhook instance + # @param options [Hash] Options to update (target_url, event, is_active, signing_secret) + # @return [self] Returns self with updated attributes + def update(options = {}) + client = Uploadcare::WebhookClient.new(config) + updated_attrs = options.slice(:target_url, :event, :is_active, :signing_secret) + + # Use current values for any missing required fields + updated_attrs[:target_url] ||= target_url + updated_attrs[:event] ||= event + updated_attrs[:is_active] = is_active if updated_attrs[:is_active].nil? + + response = client.update_webhook(id, updated_attrs[:target_url], updated_attrs[:event], + is_active: updated_attrs[:is_active], + signing_secret: updated_attrs[:signing_secret]) + + # Update instance attributes with response + response.each do |key, value| + send("#{key}=", value) if respond_to?("#{key}=") + end + + self + end + + # Delete a webhook + # @param target_url [String] The target URL of the webhook to delete + # @return nil on successful deletion + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Webhook/operation/webhookUnsubscribe + + def self.delete(target_url) + client = Uploadcare::WebhookClient.new + client.delete_webhook(target_url) + end + end +end diff --git a/lib/uploadcare/signed_url_generators/akamai_generator.rb b/lib/uploadcare/signed_url_generators/akamai_generator.rb index 870b0f40..851be245 100644 --- a/lib/uploadcare/signed_url_generators/akamai_generator.rb +++ b/lib/uploadcare/signed_url_generators/akamai_generator.rb @@ -1,68 +1,32 @@ # frozen_string_literal: true -require_relative 'base_generator' +require 'openssl' +require 'base64' module Uploadcare module SignedUrlGenerators - class AkamaiGenerator < Uploadcare::SignedUrlGenerators::BaseGenerator - UUID_REGEX = '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}' - TEMPLATE = 'https://{cdn_host}/{uuid}/?token=exp={expiration}{delimiter}acl={acl}{delimiter}hmac={token}' + class AkamaiGenerator < BaseGenerator + def generate_url(uuid, expiration = nil) + expiration ||= Time.now.to_i + 300 # 5 minutes default + acl = "/#{uuid}/" + auth_token = generate_token(acl, expiration) - def generate_url(uuid, acl = uuid, wildcard: false) - raise ArgumentError, 'Must contain valid UUID' unless valid?(uuid) - - formatted_acl = build_acl(uuid, acl, wildcard: wildcard) - expire = build_expire - signature = build_signature(expire, formatted_acl) - - TEMPLATE.gsub('{delimiter}', delimiter) - .sub('{cdn_host}', sanitized_string(cdn_host)) - .sub('{uuid}', sanitized_string(uuid)) - .sub('{acl}', formatted_acl) - .sub('{expiration}', expire) - .sub('{token}', signature) + build_url("/#{uuid}/", { + token: "exp=#{expiration}~acl=#{acl}~hmac=#{auth_token}" + }) end private - def valid?(uuid) - uuid.match(UUID_REGEX) - end - - def delimiter - '~' - end - - def build_acl(uuid, acl, wildcard: false) - if wildcard - "/#{sanitized_delimiter_path(uuid)}/*" - else - "/#{sanitized_delimiter_path(acl)}/" - end - end - - # Delimiter sanitization referenced from: https://github.com/uploadcare/pyuploadcare/blob/main/pyuploadcare/secure_url.py#L74 - def sanitized_delimiter_path(path) - sanitized_string(path).gsub('~') { |escape_char| "%#{escape_char.ord.to_s(16).downcase}" } - end - - def build_expire - (Time.now.to_i + ttl).to_s - end - - def build_signature(expire, acl) - signature = ["exp=#{expire}", "acl=#{acl}"].join(delimiter) - secret_key_bin = Array(secret_key.gsub(/\s/, '')).pack('H*') - OpenSSL::HMAC.hexdigest(algorithm, secret_key_bin, signature) + def generate_token(acl, expiration) + string_to_sign = "exp=#{expiration}~acl=#{acl}" + hmac = OpenSSL::HMAC.digest('sha256', hex_to_binary(secret_key), string_to_sign) + Base64.urlsafe_encode64(hmac, padding: false) end - # rubocop:disable Style/SlicingWithRange - def sanitized_string(string) - string = string[1..-1] if string[0] == '/' - string = string[0...-1] if string[-1] == '/' - string.strip + def hex_to_binary(hex_string) + [hex_string].pack('H*') end - # rubocop:enable Style/SlicingWithRange end end end diff --git a/lib/uploadcare/signed_url_generators/base_generator.rb b/lib/uploadcare/signed_url_generators/base_generator.rb index df5e5d84..4b045fc5 100644 --- a/lib/uploadcare/signed_url_generators/base_generator.rb +++ b/lib/uploadcare/signed_url_generators/base_generator.rb @@ -3,18 +3,23 @@ module Uploadcare module SignedUrlGenerators class BaseGenerator - attr_accessor :cdn_host, :ttl, :algorithm - attr_reader :secret_key + attr_reader :cdn_host, :secret_key - def initialize(cdn_host:, secret_key:, ttl: 300, algorithm: 'sha256') - @ttl = ttl - @algorithm = algorithm + def initialize(cdn_host:, secret_key:) @cdn_host = cdn_host @secret_key = secret_key end - def generate_url - raise NotImplementedError, "#{__method__} method not present" + def generate_url(_uuid, _expiration = nil) + raise NotImplementedError, 'Subclasses must implement generate_url method' + end + + private + + def build_url(path, query_params = {}) + uri = URI("https://#{cdn_host}#{path}") + uri.query = URI.encode_www_form(query_params) unless query_params.empty? + uri.to_s end end end diff --git a/lib/uploadcare/throttle_handler.rb b/lib/uploadcare/throttle_handler.rb new file mode 100644 index 00000000..72f16f42 --- /dev/null +++ b/lib/uploadcare/throttle_handler.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Uploadcare + # This module lets clients send request multiple times if request is throttled + module ThrottleHandler + # call given block. If ThrottleError is returned, it will wait and attempt again 4 more times + # @yield executable block (HTTP request that may be throttled) + def handle_throttling + (Uploadcare.configuration.max_throttle_attempts - 1).times do + # rubocop:disable Style/RedundantBegin + begin + return yield + rescue(Uploadcare::Exception::ThrottleError) => e + sleep(e.timeout) + end + # rubocop:enable Style/RedundantBegin + end + yield + end + end +end diff --git a/lib/uploadcare/url_builder.rb b/lib/uploadcare/url_builder.rb new file mode 100644 index 00000000..3ec4995b --- /dev/null +++ b/lib/uploadcare/url_builder.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +module Uploadcare + class UrlBuilder + attr_reader :base_url, :operations + + def initialize(source, config = Uploadcare.configuration) + @config = config + @base_url = construct_base_url(source) + @operations = [] + end + + # Image resize operations + def resize(width, height = nil) + if height.nil? + add_operation("resize/#{width}") + else + add_operation("resize/#{width}x#{height}") + end + end + + def resize_width(width) + add_operation("resize/#{width}x") + end + + def resize_height(height) + add_operation("resize/x#{height}") + end + + def scale_crop(width, height, options = {}) + operation = "scale_crop/#{width}x#{height}" + operation += "/#{options[:type]}" if options[:type] + operation += "/#{options[:offset_x]},#{options[:offset_y]}" if options[:offset_x] && options[:offset_y] + add_operation(operation) + end + + def smart_resize(width, height) + add_operation("scale_crop/#{width}x#{height}/smart") + end + + # Crop operations + def crop(width, height, options = {}) + operation = "crop/#{width}x#{height}" + operation += "/#{options[:offset_x]},#{options[:offset_y]}" if options[:offset_x] && options[:offset_y] + add_operation(operation) + end + + def crop_faces(ratio = nil) + operation = 'crop/faces' + operation += "/#{ratio}" if ratio + add_operation(operation) + end + + def crop_objects(ratio = nil) + operation = 'crop/objects' + operation += "/#{ratio}" if ratio + add_operation(operation) + end + + # Format operations + def format(fmt) + add_operation("format/#{fmt}") + end + + def quality(value) + add_operation("quality/#{value}") + end + + def progressive(value = 'yes') + add_operation("progressive/#{value}") + end + + # Effects and filters + def grayscale + add_operation('grayscale') + end + + def invert + add_operation('invert') + end + + def flip + add_operation('flip') + end + + def mirror + add_operation('mirror') + end + + def rotate(angle) + add_operation("rotate/#{angle}") + end + + def blur(strength = nil) + operation = 'blur' + operation += "/#{strength}" if strength + add_operation(operation) + end + + def sharpen(strength = nil) + operation = 'sharpen' + operation += "/#{strength}" if strength + add_operation(operation) + end + + def enhance(strength = nil) + operation = 'enhance' + operation += "/#{strength}" if strength + add_operation(operation) + end + + def brightness(value) + add_operation("brightness/#{value}") + end + + def exposure(value) + add_operation("exposure/#{value}") + end + + def gamma(value) + add_operation("gamma/#{value}") + end + + def contrast(value) + add_operation("contrast/#{value}") + end + + def saturation(value) + add_operation("saturation/#{value}") + end + + def vibrance(value) + add_operation("vibrance/#{value}") + end + + def warmth(value) + add_operation("warmth/#{value}") + end + + # Color adjustments + def max_icc_size(value) + add_operation("max_icc_size/#{value}") + end + + def srgb(value = 'true') + add_operation("srgb/#{value}") + end + + # Face detection + def detect_faces + add_operation('detect_faces') + end + + # Video operations + def video_thumbs(time) + add_operation("video/thumbs~#{time}") + end + + # Preview operation + def preview(width = nil, height = nil) + operation = 'preview' + operation += "/#{width}x#{height}" if width || height + add_operation(operation) + end + + # Filename + def filename(name) + @filename = name + self + end + + # Build the final URL + def url + return @base_url if @operations.empty? + + url_parts = [@base_url] + url_parts << @operations.map { |op| "-/#{op}/" }.join + url_parts << @filename if @filename + + url_parts.join + end + + alias to_s url + alias to_url url + + # Chain operations + def add_operation(operation) + @operations << operation + self + end + + private + + def construct_base_url(source) + case source + when Uploadcare::File + source.cdn_url.chomp('/') + when String + if source.start_with?('http://', 'https://') + source.chomp('/') + else + # Assume it's a UUID + "#{@config.cdn_url_base}#{source}" + end + else + raise ArgumentError, 'Invalid source type. Expected Uploadcare::File or String (UUID/URL)' + end + end + end +end diff --git a/lib/uploadcare/ruby/version.rb b/lib/uploadcare/version.rb similarity index 72% rename from lib/uploadcare/ruby/version.rb rename to lib/uploadcare/version.rb index bd5d0da5..7e875a7c 100644 --- a/lib/uploadcare/ruby/version.rb +++ b/lib/uploadcare/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Uploadcare - VERSION = '4.4.3' + VERSION = '5.0.0' end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 60ed59b8..00ea194f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,15 +1,40 @@ # frozen_string_literal: true +require 'simplecov' +require 'simplecov-lcov' + +SimpleCov::Formatter::LcovFormatter.config do |c| + c.report_with_single_file = true + c.single_report_path = 'coverage/lcov.info' +end + +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter + ]) + +SimpleCov.start do + add_filter '/spec/' + add_filter '/vendor/' + + add_group 'Clients', 'lib/uploadcare/clients' + add_group 'Resources', 'lib/uploadcare/resources' + add_group 'Middleware', 'lib/uploadcare/middleware' + add_group 'Signed URL Generators', 'lib/uploadcare/signed_url_generators' + + track_files 'lib/**/*.rb' + + minimum_coverage 80 + minimum_coverage_by_file 50 +end + require 'bundler/setup' -require 'dry/monads' -require 'api_struct' require 'byebug' require 'webmock/rspec' require 'uploadcare' Dir[File.expand_path(File.join(File.dirname(__FILE__), 'support', '**', '*.rb'))].each { |f| require f } RSpec.configure do |config| - include Uploadcare::Exception # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' @@ -19,4 +44,13 @@ config.expect_with :rspec do |c| c.syntax = :expect end + + config.before(:all) do + Uploadcare::Configuration.new( + public_key: 'some_public_key', + secret_key: 'some_secret_key', + auth_type: 'Uploadcare.Simple', + rest_api_root: 'https://api.uploadcare.com' + ) + end end diff --git a/spec/support/hashie.rb b/spec/support/hashie.rb deleted file mode 100644 index 76475a38..00000000 --- a/spec/support/hashie.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# Supress this warning: -# -# `You are setting a key that conflicts with a built-in method Hashie::Mash#size defined in Hash.`` -Hashie.logger.level = Logger.const_get 'ERROR' diff --git a/spec/support/reset_config.rb b/spec/support/reset_config.rb deleted file mode 100644 index a9547ecb..00000000 --- a/spec/support/reset_config.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -RSpec.configure do |config| - config.before(:each) do - Uploadcare.config.public_key = 'demopublickey' - Uploadcare.config.secret_key = 'demoprivatekey' - Uploadcare.config.auth_type = 'Uploadcare' - Uploadcare.config.multipart_size_threshold = 100 * 1024 * 1024 - end -end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb index 4f811d58..f1f064d1 100644 --- a/spec/support/vcr.rb +++ b/spec/support/vcr.rb @@ -6,8 +6,8 @@ VCR.configure do |config| config.cassette_library_dir = 'spec/fixtures/vcr_cassettes' config.hook_into :webmock - config.filter_sensitive_data('') { Uploadcare.config.public_key } - config.filter_sensitive_data('') { Uploadcare.config.secret_key } + config.filter_sensitive_data('') { Uploadcare.configuration.public_key } + config.filter_sensitive_data('') { Uploadcare.configuration.secret_key } config.before_record do |i| if i.request.body && i.request.body.size > 1024 * 1024 i.request.body = "Big string (#{i.request.body.size / (1024 * 1024)}) MB" diff --git a/spec/uploadcare/api/api_spec.rb b/spec/uploadcare/api/api_spec.rb deleted file mode 100644 index aedd2c36..00000000 --- a/spec/uploadcare/api/api_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - RSpec.describe Api do - subject { Api.new } - - it 'responds to expected REST methods' do - %i[file file_list store_files delete_files project].each do |method| - expect(subject).to respond_to(method) - end - end - - it 'responds to expected Upload methods' do - %i[upload upload_files upload_url].each do |method| - expect(subject).to respond_to(method) - end - end - - it 'views file info' do - VCR.use_cassette('rest_file_info') do - uuid = '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' - file = subject.file(uuid) - expect(file.uuid).to eq(uuid) - end - end - end -end diff --git a/spec/uploadcare/api_spec.rb b/spec/uploadcare/api_spec.rb new file mode 100644 index 00000000..332b0d7e --- /dev/null +++ b/spec/uploadcare/api_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Api do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'test_public_key', + secret_key: 'test_secret_key' + ) + end + + subject(:api) { described_class.new(config) } + + describe '#initialize' do + it 'uses provided configuration' do + expect(api.config).to eq(config) + end + + it 'uses default configuration when none provided' do + api = described_class.new + expect(api.config).to eq(Uploadcare.configuration) + end + end + + describe 'File operations' do + let(:uuid) { 'file-uuid-123' } + let(:file_instance) { instance_double(Uploadcare::File) } + + describe '#file' do + it 'retrieves file info' do + expect(Uploadcare::File).to receive(:new).with({ uuid: uuid }, config).and_return(file_instance) + expect(file_instance).to receive(:info).and_return(file_instance) + + result = api.file(uuid) + expect(result).to eq(file_instance) + end + end + + describe '#file_list' do + it 'delegates to File.list' do + options = { limit: 10 } + expect(Uploadcare::File).to receive(:list).with(options, config) + + api.file_list(options) + end + end + + describe '#store_file' do + it 'stores a file' do + expect(Uploadcare::File).to receive(:new).with({ uuid: uuid }, config).and_return(file_instance) + expect(file_instance).to receive(:store).and_return(file_instance) + + result = api.store_file(uuid) + expect(result).to eq(file_instance) + end + end + + describe '#delete_file' do + it 'deletes a file' do + expect(Uploadcare::File).to receive(:new).with({ uuid: uuid }, config).and_return(file_instance) + expect(file_instance).to receive(:delete).and_return(file_instance) + + result = api.delete_file(uuid) + expect(result).to eq(file_instance) + end + end + + describe '#batch_store' do + let(:uuids) { %w[uuid1 uuid2] } + + it 'delegates to File.batch_store' do + expect(Uploadcare::File).to receive(:batch_store).with(uuids, config) + + api.batch_store(uuids) + end + end + + describe '#batch_delete' do + let(:uuids) { %w[uuid1 uuid2] } + + it 'delegates to File.batch_delete' do + expect(Uploadcare::File).to receive(:batch_delete).with(uuids, config) + + api.batch_delete(uuids) + end + end + + describe '#local_copy' do + let(:source) { 'source-uuid' } + let(:options) { { store: true } } + + it 'delegates to File.local_copy' do + expect(Uploadcare::File).to receive(:local_copy).with(source, options, config) + + api.local_copy(source, options) + end + end + + describe '#remote_copy' do + let(:source) { 'source-uuid' } + let(:target) { 'custom_storage' } + let(:options) { { make_public: true } } + + it 'delegates to File.remote_copy' do + expect(Uploadcare::File).to receive(:remote_copy).with(source, target, options, config) + + api.remote_copy(source, target, options) + end + end + end + + describe 'Upload operations' do + describe '#upload' do + let(:input) { 'file.jpg' } + let(:options) { { store: true } } + + it 'delegates to Uploader.upload' do + expect(Uploadcare::Uploader).to receive(:upload).with(input, options, config) + + api.upload(input, options) + end + end + + describe '#upload_file' do + let(:file) { 'file.jpg' } + let(:options) { { store: true } } + + it 'delegates to Uploader.upload_file' do + expect(Uploadcare::Uploader).to receive(:upload_file).with(file, options, config) + + api.upload_file(file, options) + end + end + + describe '#upload_files' do + let(:files) { ['file1.jpg', 'file2.jpg'] } + let(:options) { { store: true } } + + it 'delegates to Uploader.upload_files' do + expect(Uploadcare::Uploader).to receive(:upload_files).with(files, options, config) + + api.upload_files(files, options) + end + end + + describe '#upload_from_url' do + let(:url) { 'https://example.com/image.jpg' } + let(:options) { { store: true } } + + it 'delegates to Uploader.upload_from_url' do + expect(Uploadcare::Uploader).to receive(:upload_from_url).with(url, options, config) + + api.upload_from_url(url, options) + end + end + + describe '#check_upload_status' do + let(:token) { 'upload-token-123' } + + it 'delegates to Uploader.check_upload_status' do + expect(Uploadcare::Uploader).to receive(:check_upload_status).with(token, config) + + api.check_upload_status(token) + end + end + end + + describe 'Group operations' do + let(:uuid) { 'group-uuid-123' } + let(:group_instance) { instance_double(Uploadcare::Group) } + + describe '#group' do + it 'retrieves group info' do + expect(Uploadcare::Group).to receive(:new).with({ id: uuid }, config).and_return(group_instance) + expect(group_instance).to receive(:info).with(uuid).and_return(group_instance) + + result = api.group(uuid) + expect(result).to eq(group_instance) + end + end + + describe '#group_list' do + it 'delegates to Group.list' do + options = { limit: 10 } + expect(Uploadcare::Group).to receive(:list).with(options, config) + + api.group_list(options) + end + end + + describe '#create_group' do + let(:files) { %w[uuid1 uuid2] } + let(:options) { {} } + + it 'delegates to Group.create' do + expect(Uploadcare::Group).to receive(:create).with(files, options, config) + + api.create_group(files, options) + end + end + end + + describe 'Project operations' do + describe '#project' do + it 'delegates to Project.info' do + expect(Uploadcare::Project).to receive(:info).with(config) + + api.project + end + end + end + + describe 'Webhook operations' do + describe '#create_webhook' do + let(:target_url) { 'https://example.com/webhook' } + let(:options) { { event: 'file.uploaded' } } + + it 'delegates to Webhook.create' do + expect(Uploadcare::Webhook).to receive(:create).with({ target_url: target_url }.merge(options), config) + + api.create_webhook(target_url, options) + end + end + + describe '#list_webhooks' do + it 'delegates to Webhook.list' do + options = { limit: 10 } + expect(Uploadcare::Webhook).to receive(:list).with(options, config) + + api.list_webhooks(options) + end + end + + describe '#update_webhook' do + let(:id) { 'webhook-id' } + let(:options) { { is_active: false } } + let(:webhook_instance) { instance_double(Uploadcare::Webhook) } + + it 'updates webhook' do + expect(Uploadcare::Webhook).to receive(:new).with({ id: id }, config).and_return(webhook_instance) + expect(webhook_instance).to receive(:update).with(options) + + api.update_webhook(id, options) + end + end + + describe '#delete_webhook' do + let(:target_url) { 'https://example.com/webhook' } + + it 'delegates to Webhook.delete' do + expect(Uploadcare::Webhook).to receive(:delete).with(target_url, config) + + api.delete_webhook(target_url) + end + end + end + + describe 'Conversion operations' do + describe '#convert_document' do + let(:paths) { ['doc-uuid'] } + let(:options) { { format: 'pdf' } } + + it 'delegates to DocumentConverter.convert' do + expect(Uploadcare::DocumentConverter).to receive(:convert).with(paths, options, config) + + api.convert_document(paths, options) + end + end + + describe '#document_conversion_status' do + let(:token) { 'conversion-token' } + + it 'delegates to DocumentConverter.status' do + expect(Uploadcare::DocumentConverter).to receive(:status).with(token, config) + + api.document_conversion_status(token) + end + end + + describe '#convert_video' do + let(:paths) { ['video-uuid'] } + let(:options) { { format: 'mp4' } } + + it 'delegates to VideoConverter.convert' do + expect(Uploadcare::VideoConverter).to receive(:convert).with(paths, options, config) + + api.convert_video(paths, options) + end + end + + describe '#video_conversion_status' do + let(:token) { 'conversion-token' } + + it 'delegates to VideoConverter.status' do + expect(Uploadcare::VideoConverter).to receive(:status).with(token, config) + + api.video_conversion_status(token) + end + end + end + + describe 'Add-ons operations' do + describe '#execute_addon' do + let(:addon_name) { 'remove_bg' } + let(:target) { 'file-uuid' } + let(:options) { { crop: true } } + + it 'delegates to AddOns.execute' do + expect(Uploadcare::AddOns).to receive(:execute).with(addon_name, target, options, config) + + api.execute_addon(addon_name, target, options) + end + end + + describe '#check_addon_status' do + let(:addon_name) { 'remove_bg' } + let(:request_id) { 'request-id' } + + it 'delegates to AddOns.status' do + expect(Uploadcare::AddOns).to receive(:status).with(addon_name, request_id, config) + + api.check_addon_status(addon_name, request_id) + end + end + end + + describe 'File metadata operations' do + let(:uuid) { 'file-uuid' } + let(:key) { 'metadata_key' } + let(:value) { 'metadata_value' } + + describe '#file_metadata' do + it 'delegates to FileMetadata.index' do + expect(Uploadcare::FileMetadata).to receive(:index).with(uuid, config) + + api.file_metadata(uuid) + end + end + + describe '#get_file_metadata' do + it 'delegates to FileMetadata.show' do + expect(Uploadcare::FileMetadata).to receive(:show).with(uuid, key, config) + + api.get_file_metadata(uuid, key) + end + end + + describe '#update_file_metadata' do + it 'delegates to FileMetadata.update' do + expect(Uploadcare::FileMetadata).to receive(:update).with(uuid, key, value, config) + + api.update_file_metadata(uuid, key, value) + end + end + + describe '#delete_file_metadata' do + it 'delegates to FileMetadata.delete' do + expect(Uploadcare::FileMetadata).to receive(:delete).with(uuid, key, config) + + api.delete_file_metadata(uuid, key) + end + end + end +end diff --git a/spec/uploadcare/authenticator_spec.rb b/spec/uploadcare/authenticator_spec.rb new file mode 100644 index 00000000..ba2327fa --- /dev/null +++ b/spec/uploadcare/authenticator_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Authenticator do + let(:public_key) { 'test_public_key' } + let(:secret_key) { 'test_secret_key' } + let(:config) do + Uploadcare::Configuration.new( + public_key: public_key, + secret_key: secret_key, + auth_type: auth_type + ) + end + let(:authenticator) { described_class.new(config) } + let(:http_method) { 'GET' } + let(:uri) { '/files/?limit=1&stored=true' } + let(:body) { '' } + + describe '#headers' do + context 'when using Uploadcare.Simple auth' do + let(:auth_type) { 'Uploadcare.Simple' } + + it 'returns correct headers with Authorization' do + headers = authenticator.headers(http_method, uri, body) + expect(headers['Authorization']).to eq("Uploadcare.Simple #{public_key}:#{secret_key}") + expect(headers['Accept']).to eq('application/vnd.uploadcare-v0.7+json') + expect(headers['Content-Type']).to eq('application/json') + expect(headers).not_to have_key('Date') + end + end + + context 'when using Uploadcare auth' do + let(:auth_type) { 'Uploadcare' } + + before { allow(Time).to receive(:now).and_return(Time.at(0)) } + + it 'returns correct headers with computed signature and Date' do + headers = authenticator.headers(http_method, uri, body) + date = Time.now.httpdate + content_md5 = Digest::MD5.hexdigest(body) + content_type = 'application/json' + expected_string_to_sign = [ + http_method, + content_md5, + content_type, + date, + uri + ].join("\n") + expected_signature = OpenSSL::HMAC.hexdigest( + OpenSSL::Digest.new('sha1'), + secret_key, + expected_string_to_sign + ) + expect(headers['Authorization']).to eq("Uploadcare #{public_key}:#{expected_signature}") + expect(headers['Date']).to eq(date) + expect(headers['Accept']).to eq('application/vnd.uploadcare-v0.7+json') + expect(headers['Content-Type']).to eq('application/json') + end + end + end +end diff --git a/spec/uploadcare/client/addons_client_spec.rb b/spec/uploadcare/client/addons_client_spec.rb deleted file mode 100644 index 0d2bb291..00000000 --- a/spec/uploadcare/client/addons_client_spec.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe AddonsClient do - subject { AddonsClient.new } - - describe 'uc_clamav_virus_scan' do - it 'scans the file for viruses' do - VCR.use_cassette('uc_clamav_virus_scan') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - params = { purge_infected: true } - response = subject.uc_clamav_virus_scan(uuid, params) - expect(response.success).to eq({ request_id: '34abf037-5384-4e38-bad4-97dd48e79acd' }) - end - end - end - - describe 'uc_clamav_virus_scan_status' do - it 'checking the status of a virus scanned file' do - VCR.use_cassette('uc_clamav_virus_scan_status') do - uuid = '34abf037-5384-4e38-bad4-97dd48e79acd' - response = subject.uc_clamav_virus_scan_status(uuid) - expect(response.success).to eq({ status: 'done' }) - end - end - end - - describe 'ws_rekognition_detect_labels' do - it 'executes aws rekognition' do - VCR.use_cassette('ws_rekognition_detect_labels') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - response = subject.ws_rekognition_detect_labels(uuid) - expect(response.success).to eq({ request_id: '0f4598dd-d168-4272-b49e-e7f9d2543542' }) - end - end - end - - describe 'ws_rekognition_detect_labels_status' do - it 'checking the status of a recognized file' do - VCR.use_cassette('ws_rekognition_detect_labels_status') do - uuid = '0f4598dd-d168-4272-b49e-e7f9d2543542' - response = subject.ws_rekognition_detect_labels_status(uuid) - expect(response.success).to eq({ status: 'done' }) - end - end - end - - describe 'remove_bg' do - it 'executes background image removal' do - VCR.use_cassette('remove_bg') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - params = { crop: true, type_level: '2' } - response = subject.remove_bg(uuid, params) - expect(response.success).to eq({ request_id: 'c3446e41-9eb0-4301-aeb4-356d0fdcf9af' }) - end - end - end - - describe 'remove_bg_status' do - it 'checking the status background image removal file' do - VCR.use_cassette('remove_bg_status') do - uuid = 'c3446e41-9eb0-4301-aeb4-356d0fdcf9af' - response = subject.remove_bg_status(uuid) - expect(response.success).to( - eq({ status: 'done', result: { file_id: 'bc37b996-916d-4ed7-b230-fa71a4290cb3' } }) - ) - end - end - end - - describe 'ws_rekognition_detect_moderation_labels' do - it 'executes aws rekognition' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - response = subject.ws_rekognition_detect_moderation_labels(uuid) - expect(response.success).to eq({ request_id: '0f4598dd-d168-4272-b49e-e7f9d2543542' }) - end - end - end - - describe 'ws_rekognition_detect_moderation_labels_status' do - it 'checking the status of a recognized file' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels_status') do - uuid = '0f4598dd-d168-4272-b49e-e7f9d2543542' - response = subject.ws_rekognition_detect_moderation_labels_status(uuid) - expect(response.success).to eq({ status: 'done' }) - end - end - end - end - end -end diff --git a/spec/uploadcare/client/conversion/document_conversion_client_spec.rb b/spec/uploadcare/client/conversion/document_conversion_client_spec.rb deleted file mode 100644 index ef4d3a87..00000000 --- a/spec/uploadcare/client/conversion/document_conversion_client_spec.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - module Conversion - RSpec.describe DocumentConversionClient do - describe 'successfull conversion' do - describe 'convert_many' do - subject { described_class.new.convert_many(array_of_params, **options) } - - shared_examples 'succeeds documents conversion' do - it 'returns a convert documents response' do - expect(subject).to be_success - end - end - - let(:array_of_params) do - [ - { - uuid: 'a4b9db2f-1591-4f4c-8f68-94018924525d', - format: 'png', - page: 1 - } - ] - end - let(:options) { { store: false } } - - context 'when all params are present', vcr: 'document_convert_convert_many' do - it_behaves_like 'succeeds documents conversion' - end - - context 'multipage conversion', vcr: 'document_convert_to_multipage' do - let(:array_of_params) do - [ - { - uuid: '23d29586-713e-4152-b400-05fb54730453', - format: 'png' - } - ] - end - let(:options) { { store: '0', save_in_group: '1' } } - - it_behaves_like 'succeeds documents conversion' - end - end - - describe 'get document conversion status' do - subject { described_class.new.get_conversion_status(token) } - - let(:token) { '21120333' } - - it 'returns a document conversion status data' do - VCR.use_cassette('document_convert_get_status') do - expect(subject).to be_success - end - end - end - end - - describe 'conversion with error' do - shared_examples 'failed document conversion' do - it 'raises a conversion error' do - VCR.use_cassette('document_convert_convert_many_with_error') do - expect(subject).to be_failure - end - end - end - - describe 'convert_many' do - subject { described_class.new.convert_many(array_of_params, **options) } - - let(:array_of_params) do - [ - { - uuid: '86c54d9a-3453-4b12-8dcc-49883ae8f084', - format: 'jpg', - page: 1 - } - ] - end - let(:options) { { store: false } } - - context 'when the target_format is not a supported' do - let(:message) { /target_format is not a supported/ } - - it_behaves_like 'failed document conversion' - end - end - end - end - end - end -end diff --git a/spec/uploadcare/client/conversion/video_convertion_client_spec.rb b/spec/uploadcare/client/conversion/video_convertion_client_spec.rb deleted file mode 100644 index f5b6bfa8..00000000 --- a/spec/uploadcare/client/conversion/video_convertion_client_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - module Conversion - RSpec.describe Uploadcare::Client::Conversion::VideoConversionClient do - describe 'successfull conversion' do - describe 'convert_many' do - subject { described_class.new.convert_many(array_of_params, **options) } - - shared_examples 'requesting video conversion' do - it 'returns a convert video response' do - VCR.use_cassette('video_convert_convert_many') do - expect(subject).to be_success - end - end - end - - let(:array_of_params) do - [ - { - uuid: 'e30112d7-3a90-4931-b2c5-688cbb46d3ac', - size: { resize_mode: 'change_ratio', width: '600', height: '400' }, - quality: 'best', - format: 'ogg', - cut: { start_time: '0:0:0.0', length: '0:0:1.0' }, - thumbs: { N: 2, number: 1 } - } - ] - end - let(:options) { { store: false } } - - context 'when all params are present' do - it_behaves_like 'requesting video conversion' - end - - %i[size quality format cut thumbs].each do |param| - context "when only :#{param} param is present" do - let(:arguments) { super().select { |k, _v| [:uuid, param].include?(k) } } - - it_behaves_like 'requesting video conversion' - end - end - end - - describe 'get video conversion status' do - subject { described_class.new.get_conversion_status(token) } - - let(:token) { '911933811' } - - it 'returns a video conversion status data' do - VCR.use_cassette('video_convert_get_status') do - expect(subject).to be_success - end - end - end - end - - describe 'conversion with error' do - shared_examples 'requesting video conversion' do - it 'raises a conversion error' do - VCR.use_cassette('video_convert_convert_many_with_error') do - expect(subject).to be_failure - end - end - end - - describe 'convert_many' do - subject { described_class.new.convert_many(array_of_params, **options) } - - let(:array_of_params) do - [ - { - uuid: 'e30112d7-3a90-4931-b2c5-688cbb46d3ac', - size: { resize_mode: 'change_ratio' }, - quality: 'best', - format: 'ogg', - cut: { start_time: '0:0:0.0', length: '0:0:1.0' }, - thumbs: { N: 2, number: 1 } - } - ] - end - let(:options) { { store: false } } - - context 'when no width and height are provided' do - let(:message) { /CDN Path error/ } - - it_behaves_like 'requesting video conversion' - end - end - end - end - end - end -end diff --git a/spec/uploadcare/client/file_client_spec.rb b/spec/uploadcare/client/file_client_spec.rb deleted file mode 100644 index fe6818bd..00000000 --- a/spec/uploadcare/client/file_client_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe FileClient do - subject { FileClient.new } - - describe 'info' do - it 'shows insider info about that file' do - VCR.use_cassette('rest_file_info') do - uuid = '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' - file = subject.info(uuid) - expect(file.value![:uuid]).to eq(uuid) - end - end - - it 'show raise argument error if public_key is blank' do - Uploadcare.config.public_key = '' - VCR.use_cassette('rest_file_info') do - uuid = '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' - expect { subject.info(uuid) }.to raise_error(AuthError, 'Public Key is blank.') - end - end - - it 'show raise argument error if secret_key is blank' do - Uploadcare.config.secret_key = '' - VCR.use_cassette('rest_file_info') do - uuid = '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' - expect { subject.info(uuid) }.to raise_error(AuthError, 'Secret Key is blank.') - end - end - - it 'show raise argument error if secret_key is nil' do - Uploadcare.config.secret_key = nil - VCR.use_cassette('rest_file_info') do - uuid = '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' - expect { subject.info(uuid) }.to raise_error(AuthError, 'Secret Key is blank.') - end - end - - it 'supports extra params like include' do - VCR.use_cassette('rest_file_info') do - uuid = '640fe4b7-7352-42ca-8d87-0e4387957157' - file = subject.info(uuid, { include: 'appdata' }) - expect(file.value![:uuid]).to eq(uuid) - expect(file.value![:appdata]).not_to be_empty - end - end - - it 'shows nothing on invalid file' do - VCR.use_cassette('rest_file_info_fail') do - uuid = 'nonexistent' - expect { subject.info(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'delete' do - it 'deletes a file' do - VCR.use_cassette('rest_file_delete') do - uuid = '158e7c82-8246-4017-9f17-0798e18c91b0' - response = subject.delete(uuid) - response_value = response.value! - expect(response_value[:datetime_removed]).not_to be_empty - expect(response_value[:uuid]).to eq(uuid) - end - end - end - - describe 'store' do - it 'changes file`s status to stored' do - VCR.use_cassette('rest_file_store') do - uuid = 'e9a9f291-cc52-4388-bf65-9feec1c75ff9' - response = subject.store(uuid) - expect(response.value![:datetime_stored]).not_to be_empty - end - end - end - end - end -end diff --git a/spec/uploadcare/client/file_list_client_spec.rb b/spec/uploadcare/client/file_list_client_spec.rb deleted file mode 100644 index e0ac1a37..00000000 --- a/spec/uploadcare/client/file_list_client_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe FileListClient do - subject { FileListClient.new } - - describe 'file_list' do - it 'returns paginated list with files data' do - VCR.use_cassette('rest_file_list') do - file_list = subject.file_list.value! - expected_fields = %i[total per_page results] - expected_fields.each do |field| - expect(file_list[field]).not_to be_nil - end - end - end - - it 'processes options' do - VCR.use_cassette('rest_file_list_limited') do - first_page = subject.file_list(limit: 2).value! - second_page = subject.file_list(limit: 2).value! - expect(first_page[:per_page]).to eq(2) - expect(first_page[:results].length).to eq(2) - expect(first_page[:results]).not_to eq(second_page[:result]) - end - end - end - - describe 'batch_store' do - it 'changes files` statuses to stored' do - VCR.use_cassette('rest_file_batch_store') do - uuids = %w[e9a9f291-cc52-4388-bf65-9feec1c75ff9 c724feac-86f7-447c-b2d6-b0ced220173d] - response = subject.batch_store(uuids) - response_value = response.value! - expect(uuids.all? { |uuid| response_value.to_s.include?(uuid) }).to be true - end - end - - context 'invalid uuids' do - it 'returns a list of problems' do - VCR.use_cassette('rest_file_batch_store_fail') do - uuids = %w[nonexistent other_nonexistent] - response = subject.batch_store(uuids) - expect(response.success[:files]).to be_nil - expect(response.success[:problems]).not_to be_empty - end - end - end - end - - describe 'batch_delete' do - it 'changes files` statuses to stored' do - VCR.use_cassette('rest_file_batch_delete') do - uuids = %w[935ff093-a5cf-48c5-81cf-208511bac6e6 63be5a6e-9b6b-454b-8aec-9136d5f83d0c] - response = subject.batch_delete(uuids) - response_value = response.value! - expect(response_value[:result][0][:datetime_removed]).not_to be_empty - end - end - - context 'invalid uuids' do - it 'returns a list of problems' do - VCR.use_cassette('rest_file_batch_delete_fail') do - uuids = %w[nonexistent other_nonexistent] - response = subject.batch_delete(uuids) - expect(response.success[:files]).to be_nil - expect(response.success[:problems]).not_to be_empty - end - end - end - end - end - end -end diff --git a/spec/uploadcare/client/file_metadata_client_spec.rb b/spec/uploadcare/client/file_metadata_client_spec.rb deleted file mode 100644 index 3edf8a00..00000000 --- a/spec/uploadcare/client/file_metadata_client_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe FileMetadataClient do - subject { FileMetadataClient.new } - - let(:uuid) { '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' } - let(:key) { 'subsystem' } - - describe 'index' do - it 'shows file metadata keys and values' do - VCR.use_cassette('file_metadata_index') do - response = subject.index(uuid) - expect(response.value![:subsystem]).to eq('test') - end - end - end - - describe 'show' do - it 'shows file metadata value by key' do - VCR.use_cassette('file_metadata_show') do - response = subject.show(uuid, key) - expect(response.value!).to eq('test') - end - end - end - - describe 'update' do - it 'updates file metadata value by key' do - VCR.use_cassette('file_metadata_update') do - new_value = 'new test value' - response = subject.update(uuid, key, new_value) - expect(response.value!).to eq(new_value) - end - end - end - - describe 'delete' do - it 'deletes a file metadata key' do - VCR.use_cassette('file_metadata_delete') do - response = subject.delete(uuid, key) - expect(response.value!).to be_nil - expect(response.success?).to be_truthy - end - end - end - end - end -end diff --git a/spec/uploadcare/client/group_client_spec.rb b/spec/uploadcare/client/group_client_spec.rb deleted file mode 100644 index 1e97ed6e..00000000 --- a/spec/uploadcare/client/group_client_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe GroupClient do - subject { GroupClient.new } - let!(:uuids) { %w[8ca6e9fa-c6dd-4027-a0fc-b620611f7023 b8a11440-6fcc-4285-a24d-cc8c60259fec] } - - describe 'create' do - it 'creates a group' do - VCR.use_cassette('upload_create_group') do - response = subject.create(uuids) - response_body = response.success - expect(response_body[:files_count]).to eq 2 - %i[id datetime_created datetime_stored files_count cdn_url url files].each do |key| - expect(response_body).to have_key key - end - expect(response_body[:url]).to include 'https://api.uploadcare.com/groups' - end - end - context 'array of Entity::Files' do - it 'creates a group' do - VCR.use_cassette('upload_create_group_from_files') do - files = uuids.map { |uuid| Uploadcare::Entity::File.new(uuid: uuid) } - response = subject.create(files) - response_body = response.success - expect(response_body[:files_count]).to eq 2 - end - end - end - end - - describe 'info' do - it 'returns group info' do - VCR.use_cassette('upload_group_info') do - response = subject.info('bbc75785-9016-4656-9c6e-64a76b45b0b8~2') - response_body = response.success - %i[id datetime_created datetime_stored files_count cdn_url url files].each do |key| - expect(response_body).to have_key key - end - end - end - end - end - end -end diff --git a/spec/uploadcare/client/multipart_upload/chunks_client_spec.rb b/spec/uploadcare/client/multipart_upload/chunks_client_spec.rb deleted file mode 100644 index 4296cc50..00000000 --- a/spec/uploadcare/client/multipart_upload/chunks_client_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - module MultipartUpload - RSpec.describe ChunksClient do - subject { ChunksClient } - # Replace this file with actual big file when rewriting fixtures - let!(:big_file) { ::File.open('spec/fixtures/big.jpeg') } - - describe 'upload_parts' do - it 'returns raw document part data' do - VCR.use_cassette('amazon_upload') do - stub = stub_request(:put, /uploadcare.s3-accelerate.amazonaws.com/) - start_response = MultipartUploaderClient.new.upload_start(big_file) - subject.upload_chunks(big_file, start_response.success[:parts]) - expect(stub).to have_been_requested.at_least_times(3) - end - end - end - end - end - end -end diff --git a/spec/uploadcare/client/multipart_upload_client_spec.rb b/spec/uploadcare/client/multipart_upload_client_spec.rb deleted file mode 100644 index 5842eb59..00000000 --- a/spec/uploadcare/client/multipart_upload_client_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe MultipartUploaderClient do - subject { MultipartUploaderClient.new } - let!(:small_file) { ::File.open('spec/fixtures/kitten.jpeg') } - # Replace this file with actual big file when rewriting fixtures - let!(:big_file) { ::File.open('spec/fixtures/big.jpeg') } - - describe 'upload_start' do - context 'small file' do - it 'doesnt upload small files' do - VCR.use_cassette('upload_multipart_upload_start_small') do - expect { subject.upload_start(small_file) }.to raise_error(RequestError) - end - end - end - - context 'large file' do - it 'returns links for upload' do - allow_any_instance_of(HTTP::FormData::File).to receive(:size).and_return(100 * 1024 * 1024) - VCR.use_cassette('upload_multipart_upload_start_large') do - response = subject.upload_start(small_file) - expect(response.success[:parts].count).to eq 20 - end - end - end - end - - describe 'upload_complete' do - context 'unfinished' do - it 'informs about unfinished upload' do - VCR.use_cassette('upload_multipart_upload_complete_unfinished') do - uuid = '7d9f495a-2834-4a2a-a2b3-07dbaf80ac79' - msg = 'File size mismatch. Not all parts uploaded?' - expect { subject.upload_complete(uuid) }.to raise_error(RequestError, /#{msg}/) - end - end - end - - context 'wrong uid' do - it 'informs that file is not found' do - VCR.use_cassette('upload_multipart_upload_complete_wrong_id') do - msg = 'File is not found' - expect { subject.upload_complete('nonexistent') }.to raise_error(RequestError, /#{msg}/) - end - end - end - - context 'already uploaded' do - it 'returns file data' do - VCR.use_cassette('upload_multipart_upload_complete') do - uuid = 'd8c914e3-3aef-4976-b0b6-855a9638da2d' - msg = 'File is already uploaded' - expect { subject.upload_complete(uuid) }.to raise_error(RequestError, /#{msg}/) - end - end - end - end - - describe 'upload' do - it 'does the entire multipart upload routine' do - VCR.use_cassette('upload_multipart_upload') do - # Minimum size for size to be valid for multiupload is 10 mb - Uploadcare.config.multipart_size_threshold = 10 * 1024 * 1024 - response = subject.upload(big_file) - response_value = response.value! - expect(response_value[:uuid]).not_to be_empty - end - end - - it 'returns server answer if file is too small' do - VCR.use_cassette('upload_multipart_upload_small') do - msg = 'File size can not be less than 10485760 bytes' - expect { subject.upload(small_file) }.to raise_error(RequestError, /#{msg}/) - end - end - end - end - end -end diff --git a/spec/uploadcare/client/project_client_spec.rb b/spec/uploadcare/client/project_client_spec.rb deleted file mode 100644 index 72a8e6cd..00000000 --- a/spec/uploadcare/client/project_client_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe ProjectClient do - before do - Uploadcare.config.public_key = 'foo' - end - - it 'requests info about target project' do - VCR.use_cassette('project') do - response = ProjectClient.new.show - expect(response.value![:pub_key]).to eq(Uploadcare.config.public_key) - end - end - end - end -end diff --git a/spec/uploadcare/client/rest_group_client_spec.rb b/spec/uploadcare/client/rest_group_client_spec.rb deleted file mode 100644 index 654ed506..00000000 --- a/spec/uploadcare/client/rest_group_client_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe RestGroupClient do - subject { RestGroupClient.new } - - describe 'store' do - it 'stores all files in a group' do - VCR.use_cassette('rest_store_group') do - group_id = '47e6cf32-e5a8-4ff4-b48f-14d7304b42dd~2' - response = subject.store(group_id) - expect(response.success).to be_nil - end - end - end - - describe 'info' do - it 'gets a file group by its ID.' do - VCR.use_cassette('rest_info_group') do - group_id = '47e6cf32-e5a8-4ff4-b48f-14d7304b42dd~2' - response = subject.info(group_id) - response_body = response.success - expect(response_body[:files_count]).to eq(2) - %i[id datetime_created files_count cdn_url url files].each { |key| expect(response_body).to have_key(key) } - end - end - end - - describe 'list' do - it 'returns paginated list of groups' do - VCR.use_cassette('rest_list_groups') do - response = subject.list - response_value = response.value! - expect(response_value[:results]).to be_a_kind_of(Array) - expect(response_value[:total]).to be_a_kind_of(Integer) - end - end - - it 'accepts params' do - VCR.use_cassette('rest_list_groups_limited') do - response = subject.list(limit: 2) - response_value = response.value! - expect(response_value[:per_page]).to eq 2 - end - end - end - - describe 'delete' do - it 'deletes a file group' do - VCR.use_cassette('upload_group_delete') do - response = subject.delete('bbc75785-9016-4656-9c6e-64a76b45b0b8~2') - expect(response.value!).to be_nil - expect(response.success?).to be_truthy - end - end - end - end - end -end diff --git a/spec/uploadcare/client/uploader_client_spec.rb b/spec/uploadcare/client/uploader_client_spec.rb deleted file mode 100644 index d4bc797f..00000000 --- a/spec/uploadcare/client/uploader_client_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe UploaderClient do - subject { described_class.new } - - describe 'upload' do - let(:file) { ::File.open('spec/fixtures/kitten.jpeg') } - let(:another_file) { ::File.open('spec/fixtures/another_kitten.jpeg') } - - it 'uploads a file' do - VCR.use_cassette('upload_upload') do - response = subject.upload(file, metadata: { subsystem: 'test' }) - expect(response.success?).to be true - end - end - - it 'uploads multiple files in one request' do - VCR.use_cassette('upload_upload_many') do - response = subject.upload_many([file, another_file]) - expect(response.success?).to be true - expect(response.success.length).to eq 2 - end - end - end - end - end -end diff --git a/spec/uploadcare/client/webhook_client_spec.rb b/spec/uploadcare/client/webhook_client_spec.rb deleted file mode 100644 index d3857027..00000000 --- a/spec/uploadcare/client/webhook_client_spec.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Client - RSpec.describe WebhookClient do - subject { WebhookClient.new } - - describe 'create' do - shared_examples 'creating a webhook' do - it 'creates a webhook' do - VCR.use_cassette('rest_webhook_create') do - response = subject.create(params) - response_value = response.value! - - expect(response_value[:id]).not_to be nil - end - end - - it 'sends the :post with params' do - VCR.use_cassette('rest_webhook_create') do - expect_any_instance_of(described_class).to receive(:post).with( - uri: '/webhooks/', - content: expected_params.to_json - ) - subject.create(params) - end - end - end - - let(:params) { { target_url: 'http://ohmyz.sh', event: 'file.uploaded' } } - - context 'when a new webhook is enabled' do - let(:is_active) { true } - let(:expected_params) { params } - - context 'and when sending "true"' do - it_behaves_like 'creating a webhook' do - let(:params) { super().merge(is_active: true) } - end - end - - context 'and when sending "nil"' do - it_behaves_like 'creating a webhook' do - let(:expected_params) { params.merge(is_active: true) } - let(:params) { super().merge(is_active: nil) } - end - end - - context 'and when not sending the param' do - let(:expected_params) { params.merge(is_active: true) } - it_behaves_like 'creating a webhook' - end - - context 'and when sending a signing secret' do - let(:params) do - super().merge(is_active: true, signing_secret: '1234') - end - - it 'sends the :post with params' do - VCR.use_cassette('rest_webhook_create') do - expect_any_instance_of(described_class).to receive(:post).with( - uri: '/webhooks/', - content: params.to_json - ) - subject.create(params) - end - end - end - end - - context 'when a new webhook is disabled' do - let(:is_active) { false } - let(:expected_params) { params } - - context 'and when sending "false"' do - it_behaves_like 'creating a webhook' do - let(:params) { super().merge(is_active: false) } - end - end - end - end - - describe 'list' do - it 'lists an array of webhooks' do - VCR.use_cassette('rest_webhook_list') do - response = subject.list - response_value = response.value! - expect(response_value).to be_a_kind_of(Array) - end - end - end - - describe 'delete' do - it 'destroys a webhook' do - VCR.use_cassette('rest_webhook_destroy') do - response = subject.delete('http://example.com') - response_value = response.value! - expect(response_value).to be_nil - expect(response.success?).to be true - end - end - end - - describe 'update' do - it 'updates a webhook' do - VCR.use_cassette('rest_webhook_update') do - sub_id = 887_447 - target_url = 'https://github.com' - is_active = false - sign_secret = '1234' - response = subject.update(sub_id, target_url: target_url, is_active: is_active, signing_secret: sign_secret) - response_value = response.value! - expect(response_value[:id]).to eq(sub_id) - expect(response_value[:target_url]).to eq(target_url) - expect(response_value[:is_active]).to eq(is_active) - end - end - end - end - end -end diff --git a/spec/uploadcare/client_spec.rb b/spec/uploadcare/client_spec.rb new file mode 100644 index 00000000..f146dec0 --- /dev/null +++ b/spec/uploadcare/client_spec.rb @@ -0,0 +1,434 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Client do + let(:config) { Uploadcare::Configuration.new(public_key: 'test_public', secret_key: 'test_secret') } + let(:client) { described_class.new(config) } + + describe '#initialize' do + context 'with Configuration object' do + it 'uses the provided configuration' do + expect(client.config).to eq(config) + end + end + + context 'with options hash' do + let(:client) { described_class.new(public_key: 'test_public', secret_key: 'test_secret') } + + it 'creates a new configuration' do + expect(client.config).to be_a(Uploadcare::Configuration) + expect(client.config.public_key).to eq('test_public') + end + end + + it 'sets up default middleware' do + logger = instance_double(Logger) + config = Uploadcare::Configuration.new( + public_key: 'test_public', + secret_key: 'test_secret', + max_request_tries: 3, + logger: logger + ) + client = described_class.new(config) + + # Should have added retry and logger middleware + expect(client.instance_variable_get(:@middleware).size).to eq(2) + expect(client.instance_variable_get(:@middleware).map { |m| m[:klass] }).to eq([ + Uploadcare::Middleware::Retry, + Uploadcare::Middleware::Logger + ]) + end + end + + describe '#use' do + let(:test_middleware) do + Class.new do + def initialize(app, options = {}) + @app = app + @options = options + end + + def call(env) + @app.call(env) + end + end + end + + it 'adds middleware to the stack' do + client.use(test_middleware, { option: 'value' }) + middleware = client.instance_variable_get(:@middleware) + + expect(middleware.last[:klass]).to eq(test_middleware) + expect(middleware.last[:options]).to eq({ option: 'value' }) + end + + it 'returns self for chaining' do + expect(client.use(test_middleware)).to eq(client) + end + end + + describe '#remove' do + let(:removable_middleware) do + Class.new do + def initialize(app, _options = {}) + @app = app + end + + def call(env) + @app.call(env) + end + end + end + + it 'removes middleware from the stack' do + client.use(removable_middleware) + expect(client.instance_variable_get(:@middleware).any? { |m| m[:klass] == removable_middleware }).to be true + + client.remove(removable_middleware) + expect(client.instance_variable_get(:@middleware).any? { |m| m[:klass] == removable_middleware }).to be false + end + + it 'returns self for chaining' do + expect(client.remove(removable_middleware)).to eq(client) + end + end + + describe '#request' do + it 'builds environment hash correctly' do + allow(client).to receive(:execute_request) do |env| + expect(env[:method]).to eq(:get) + expect(env[:url]).to eq('https://api.uploadcare.com/test') + expect(env[:request_headers]).to eq({ 'X-Test' => 'value' }) + expect(env[:body]).to eq({ data: 'test' }) + expect(env[:params]).to eq({ query: 'param' }) + expect(env[:config]).to eq(config) + { status: 200, headers: {}, body: {} } + end + + client.request(:get, 'https://api.uploadcare.com/test', { + headers: { 'X-Test' => 'value' }, + body: { data: 'test' }, + params: { query: 'param' } + }) + end + + it 'executes middleware stack in correct order' do + call_order = [] + + first_middleware = Class.new do + define_method :initialize do |app, _options = {}| + @app = app + end + + define_method :call do |env| + call_order << :first + @app.call(env) + end + end + + second_middleware = Class.new do + define_method :initialize do |app, _options = {}| + @app = app + end + + define_method :call do |env| + call_order << :second + @app.call(env) + end + end + + client.use(first_middleware) + client.use(second_middleware) + + allow(client).to receive(:execute_request) do |_env| + call_order << :base + { status: 200, headers: {}, body: {} } + end + + client.request(:get, 'https://api.uploadcare.com/test') + + expect(call_order).to eq(%i[second first base]) + end + end + + describe 'Resource accessors' do + describe '#files' do + it 'returns a FileResource instance' do + expect(client.files).to be_a(Uploadcare::Client::FileResource) + end + + it 'memoizes the resource' do + expect(client.files).to be(client.files) + end + end + + describe '#uploads' do + it 'returns an UploadResource instance' do + expect(client.uploads).to be_a(Uploadcare::Client::UploadResource) + end + + it 'memoizes the resource' do + expect(client.uploads).to be(client.uploads) + end + end + + describe '#groups' do + it 'returns a GroupResource instance' do + expect(client.groups).to be_a(Uploadcare::Client::GroupResource) + end + + it 'memoizes the resource' do + expect(client.groups).to be(client.groups) + end + end + + describe '#projects' do + it 'returns a ProjectResource instance' do + expect(client.projects).to be_a(Uploadcare::Client::ProjectResource) + end + + it 'memoizes the resource' do + expect(client.projects).to be(client.projects) + end + end + + describe '#webhooks' do + it 'returns a WebhookResource instance' do + expect(client.webhooks).to be_a(Uploadcare::Client::WebhookResource) + end + + it 'memoizes the resource' do + expect(client.webhooks).to be(client.webhooks) + end + end + + describe '#add_ons' do + it 'returns an AddOnResource instance' do + expect(client.add_ons).to be_a(Uploadcare::Client::AddOnResource) + end + + it 'memoizes the resource' do + expect(client.add_ons).to be(client.add_ons) + end + end + end + + describe 'FileResource' do + let(:file_resource) { client.files } + + describe '#list' do + it 'delegates to Uploadcare::File.list' do + expect(Uploadcare::File).to receive(:list).with({ limit: 10 }, config) + file_resource.list(limit: 10) + end + end + + describe '#find' do + it 'creates a File instance and calls info' do + file = instance_double(Uploadcare::File) + expect(Uploadcare::File).to receive(:new).with({ uuid: 'test-uuid' }, config).and_return(file) + expect(file).to receive(:info) + + file_resource.find('test-uuid') + end + end + + describe '#store' do + it 'creates a File instance and calls store' do + file = instance_double(Uploadcare::File) + expect(Uploadcare::File).to receive(:new).with({ uuid: 'test-uuid' }, config).and_return(file) + expect(file).to receive(:store) + + file_resource.store('test-uuid') + end + end + + describe '#delete' do + it 'creates a File instance and calls delete' do + file = instance_double(Uploadcare::File) + expect(Uploadcare::File).to receive(:new).with({ uuid: 'test-uuid' }, config).and_return(file) + expect(file).to receive(:delete) + + file_resource.delete('test-uuid') + end + end + + describe '#batch_store' do + it 'delegates to Uploadcare::File.batch_store' do + uuids = %w[uuid1 uuid2] + expect(Uploadcare::File).to receive(:batch_store).with(uuids, config) + file_resource.batch_store(uuids) + end + end + + describe '#batch_delete' do + it 'delegates to Uploadcare::File.batch_delete' do + uuids = %w[uuid1 uuid2] + expect(Uploadcare::File).to receive(:batch_delete).with(uuids, config) + file_resource.batch_delete(uuids) + end + end + + describe '#local_copy' do + it 'delegates to Uploadcare::File.local_copy' do + expect(Uploadcare::File).to receive(:local_copy).with('source-uuid', { metadata: true }, config) + file_resource.local_copy('source-uuid', metadata: true) + end + end + + describe '#remote_copy' do + it 'delegates to Uploadcare::File.remote_copy' do + expect(Uploadcare::File).to receive(:remote_copy).with('source', 'target', { make_public: true }, config) + file_resource.remote_copy('source', 'target', make_public: true) + end + end + end + + describe 'UploadResource' do + let(:upload_resource) { client.uploads } + + describe '#upload' do + it 'delegates to Uploadcare::Uploader.upload' do + expect(Uploadcare::Uploader).to receive(:upload).with('input', { store: true }, config) + upload_resource.upload('input', store: true) + end + end + + describe '#from_url' do + it 'delegates to Uploadcare::Uploader.upload_from_url' do + expect(Uploadcare::Uploader).to receive(:upload_from_url).with('http://example.com', { store: true }, config) + upload_resource.from_url('http://example.com', store: true) + end + end + + describe '#from_file' do + it 'delegates to Uploadcare::Uploader.upload_file' do + file = double('file') + expect(Uploadcare::Uploader).to receive(:upload_file).with(file, { store: true }, config) + upload_resource.from_file(file, store: true) + end + end + + describe '#multiple' do + it 'delegates to Uploadcare::Uploader.upload_files' do + files = [double('file1'), double('file2')] + expect(Uploadcare::Uploader).to receive(:upload_files).with(files, { store: true }, config) + upload_resource.multiple(files, store: true) + end + end + + describe '#status' do + it 'delegates to Uploadcare::Uploader.check_upload_status' do + expect(Uploadcare::Uploader).to receive(:check_upload_status).with('token123', config) + upload_resource.status('token123') + end + end + end + + describe 'GroupResource' do + let(:group_resource) { client.groups } + + describe '#list' do + it 'delegates to Uploadcare::Group.list' do + expect(Uploadcare::Group).to receive(:list).with({ limit: 10 }, config) + group_resource.list(limit: 10) + end + end + + describe '#find' do + it 'creates a Group instance and calls info' do + group = instance_double(Uploadcare::Group) + expect(Uploadcare::Group).to receive(:new).with({ id: 'test-uuid' }, config).and_return(group) + expect(group).to receive(:info).with('test-uuid') + + group_resource.find('test-uuid') + end + end + + describe '#create' do + it 'delegates to Uploadcare::Group.create' do + files = %w[file1 file2] + expect(Uploadcare::Group).to receive(:create).with(files, { callback: 'url' }, config) + group_resource.create(files, callback: 'url') + end + end + + describe '#delete' do + it 'creates a Group instance and calls delete' do + group = instance_double(Uploadcare::Group) + expect(Uploadcare::Group).to receive(:new).with({ id: 'test-uuid' }, config).and_return(group) + expect(group).to receive(:delete).with('test-uuid') + + group_resource.delete('test-uuid') + end + end + end + + describe 'ProjectResource' do + let(:project_resource) { client.projects } + + describe '#info' do + it 'delegates to Uploadcare::Project.info' do + expect(Uploadcare::Project).to receive(:info).with(config) + project_resource.info + end + end + end + + describe 'WebhookResource' do + let(:webhook_resource) { client.webhooks } + + describe '#list' do + it 'delegates to Uploadcare::Webhook.list' do + expect(Uploadcare::Webhook).to receive(:list).with({ limit: 10 }, config) + webhook_resource.list(limit: 10) + end + end + + describe '#create' do + it 'delegates to Uploadcare::Webhook.create' do + expect(Uploadcare::Webhook).to receive(:create).with( + { target_url: 'http://example.com', event: 'file.uploaded' }, + config + ) + webhook_resource.create('http://example.com', event: 'file.uploaded') + end + end + + describe '#update' do + it 'creates a Webhook instance and calls update' do + webhook = instance_double(Uploadcare::Webhook) + expect(Uploadcare::Webhook).to receive(:new).with({ id: 123 }, config).and_return(webhook) + expect(webhook).to receive(:update).with({ is_active: false }) + + webhook_resource.update(123, is_active: false) + end + end + + describe '#delete' do + it 'delegates to Uploadcare::Webhook.delete' do + expect(Uploadcare::Webhook).to receive(:delete).with('http://example.com', config) + webhook_resource.delete('http://example.com') + end + end + end + + describe 'AddOnResource' do + let(:addon_resource) { client.add_ons } + + describe '#execute' do + it 'delegates to Uploadcare::AddOns.execute' do + expect(Uploadcare::AddOns).to receive(:execute).with('aws_rekognition', 'target-uuid', { param: 'value' }, config) + addon_resource.execute('aws_rekognition', 'target-uuid', param: 'value') + end + end + + describe '#status' do + it 'delegates to Uploadcare::AddOns.status' do + expect(Uploadcare::AddOns).to receive(:status).with('aws_rekognition', 'request-id', config) + addon_resource.status('aws_rekognition', 'request-id') + end + end + end +end diff --git a/spec/uploadcare/clients/add_ons_client_spec.rb b/spec/uploadcare/clients/add_ons_client_spec.rb new file mode 100644 index 00000000..fe4f7a1b --- /dev/null +++ b/spec/uploadcare/clients/add_ons_client_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::AddOnsClient do + let(:client) { described_class.new } + let(:rest_api_root) { Uploadcare.configuration.rest_api_root } + + describe '#aws_rekognition_detect_labels' do + let(:uuid) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:path) { '/addons/aws_rekognition_detect_labels/execute/' } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:request_body) { { target: uuid } } + + subject { client.aws_rekognition_detect_labels(uuid) } + + context 'when the request is successful' do + let(:response_body) { { 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' } } + + before do + stub_request(:post, full_url) + .with(body: request_body.to_json) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it { is_expected.to eq(response_body) } + it 'returns a valid request ID' do + expect(subject['request_id']).to eq('8db3c8b4-2dea-4146-bcdb-63387e2b33c1') + end + end + + context 'when the request fails' do + before do + stub_request(:post, full_url) + .with(body: request_body.to_json) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequestError' do + expect { subject }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end + + describe '#aws_rekognition_detect_labels_status' do + let(:request_id) { 'd1fb31c6-ed34-4e21-bdc3-4f1485f58e21' } + let(:path) { '/addons/aws_rekognition_detect_labels/execute/status/' } + let(:params) { { request_id: request_id } } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.aws_rekognition_detect_labels_status(request_id) } + + context 'when the request is successful' do + let(:response_body) { { 'status' => 'in_progress' } } + + before do + stub_request(:get, full_url) + .with(query: params) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it { is_expected.to eq(response_body) } + it 'returns the correct status' do + expect(subject['status']).to eq('in_progress') + end + end + + context 'when the request fails' do + before do + stub_request(:get, full_url) + .with(query: params) + .to_return( + status: 404, + body: { 'detail' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises a NotFoundError' do + expect { subject }.to raise_error(Uploadcare::NotFoundError) + end + end + end + + describe '#aws_rekognition_detect_moderation_labels' do + let(:uuid) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:response_body) do + { + 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' + } + end + + before do + stub_request(:post, 'https://api.uploadcare.com/addons/aws_rekognition_detect_moderation_labels/execute/') + .with(body: { target: uuid }) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the request ID' do + response = client.aws_rekognition_detect_moderation_labels(uuid) + expect(response).to eq(response_body) + end + end + describe '#aws_rekognition_detect_moderation_labels_status' do + let(:request_id) { 'd1fb31c6-ed34-4e21-bdc3-4f1485f58e21' } + let(:response_body) do + { + 'status' => 'in_progress' + } + end + + before do + stub_request(:get, 'https://api.uploadcare.com/addons/aws_rekognition_detect_moderation_labels/execute/status/') + .with(query: { request_id: request_id }) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the status' do + response = client.aws_rekognition_detect_moderation_labels_status(request_id) + expect(response).to eq(response_body) + end + end + + describe '#uc_clamav_virus_scan' do + let(:uuid) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:params) { { purge_infected: true } } + let(:response_body) do + { + 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' + } + end + + before do + stub_request(:post, 'https://api.uploadcare.com/addons/uc_clamav_virus_scan/execute/') + .with(body: { target: uuid, purge_infected: true }.to_json) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the request ID' do + response = client.uc_clamav_virus_scan(uuid, params) + expect(response).to eq(response_body) + end + end + + describe '#uc_clamav_virus_scan_status' do + let(:request_id) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:response_body) do + { + 'status' => 'in_progress' + } + end + + before do + stub_request(:get, 'https://api.uploadcare.com/addons/uc_clamav_virus_scan/execute/status/') + .with(query: { request_id: request_id }) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the status' do + response = client.uc_clamav_virus_scan_status(request_id) + expect(response).to eq(response_body) + end + end + + describe '#remove_bg' do + let(:uuid) { '21975c81-7f57-4c7a-aef9-acfe28779f78' } + let(:params) { { crop: true, type_level: '2' } } + let(:response_body) do + { + 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' + } + end + + before do + stub_request(:post, 'https://api.uploadcare.com/addons/remove_bg/execute/') + .with(body: { target: uuid, params: params }.to_json) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the request_id' do + response = client.remove_bg(uuid, params) + expect(response).to eq(response_body) + end + end + + describe '#remove_bg_status' do + let(:request_id) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:response_body) do + { + 'status' => 'done', + 'result' => { 'file_id' => '21975c81-7f57-4c7a-aef9-acfe28779f78' } + } + end + + before do + stub_request(:get, 'https://api.uploadcare.com/addons/remove_bg/execute/status/') + .with(query: { request_id: request_id }) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the status and result' do + response = client.remove_bg_status(request_id) + expect(response).to eq(response_body) + end + end +end diff --git a/spec/uploadcare/clients/document_converter_client_spec.rb b/spec/uploadcare/clients/document_converter_client_spec.rb new file mode 100644 index 00000000..762ea0ec --- /dev/null +++ b/spec/uploadcare/clients/document_converter_client_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::DocumentConverterClient do + let(:client) { described_class.new } + let(:rest_api_root) { Uploadcare.configuration.rest_api_root } + + describe '#info' do + let(:uuid) { SecureRandom.uuid } + let(:path) { "/convert/document/#{uuid}/" } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.info(uuid) } + + context 'when the request is successful' do + let(:response_body) do + { + 'format' => { 'name' => 'pdf', 'conversion_formats' => [{ 'name' => 'txt' }] }, + 'converted_groups' => { 'pdf' => 'group_uuid~1' }, + 'error' => nil + } + end + + before do + stub_request(:get, full_url) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it { is_expected.to eq(response_body) } + end + + context 'when the request returns an error' do + before do + stub_request(:get, full_url) + .to_return(status: 404, body: { 'detail' => 'Not found' }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'raises a NotFoundError' do + expect { client.info(uuid) }.to raise_error(Uploadcare::NotFoundError) + end + end + end + + describe '#convert_document' do + let(:path) { '/convert/document/' } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:document_params) { { uuid: 'doc_uuid', format: :pdf } } + let(:options) { { store: true, save_in_group: false } } + let(:paths) { ['doc_uuid/document/-/format/pdf/'] } + + subject { client.convert_document(paths, options) } + + context 'when the request is successful' do + let(:response_body) do + { + 'problems' => {}, + 'result' => [ + { + 'original_source' => 'doc_uuid/document/-/format/pdf/', + 'token' => 445_630_631, + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d' + } + ] + } + end + + before do + stub_request(:post, full_url) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it { is_expected.to eq(response_body) } + end + end + + describe '#status' do + let(:token) { 123_456_789 } + let(:path) { "/convert/document/status/#{token}/" } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.status(token) } + + context 'when the request is successful' do + let(:response_body) do + { + 'status' => 'processing', + 'error' => nil, + 'result' => { 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d' } + } + end + + before do + stub_request(:get, full_url) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it { is_expected.to eq(response_body) } + end + end +end diff --git a/spec/uploadcare/clients/file_client_spec.rb b/spec/uploadcare/clients/file_client_spec.rb new file mode 100644 index 00000000..78fd3b55 --- /dev/null +++ b/spec/uploadcare/clients/file_client_spec.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::FileClient do + let(:client) { described_class.new } + let(:rest_api_root) { Uploadcare.configuration.rest_api_root } + + describe '#list' do + let(:path) { '/files/' } + let(:params) { { 'limit' => 10, 'ordering' => '-datetime_uploaded' } } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.list(params) } + + context 'when the request is successful' do + let(:response_body) do + { + 'next' => nil, + 'previous' => nil, + 'per_page' => 10, + 'results' => [ + { + 'uuid' => 'file_uuid_1', + 'original_filename' => 'file1.jpg', + 'size' => 12_345 + }, + { + 'uuid' => 'file_uuid_2', + 'original_filename' => 'file2.jpg', + 'size' => 67_890 + } + ], + 'total' => 2 + } + end + + before do + stub_request(:get, full_url) + .with( + query: params + ) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it { is_expected.to eq(response_body) } + end + + context 'when the request returns an error' do + before do + stub_request(:get, full_url) + .with( + query: params + ) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequestError' do + expect { client.list(params) }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end + + describe '#store' do + let(:uuid) { SecureRandom.uuid } + let(:path) { "/files/#{uuid}/storage/" } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.store(uuid) } + + context 'when the request is successful' do + let(:response_body) do + { + datetime_removed: nil, + datetime_stored: '2018-11-26T12:49:10.477888Z', + datetime_uploaded: '2018-11-26T12:49:09.945335Z', + variations: nil, + is_image: true, + is_ready: true, + mime_type: 'image/jpeg', + original_file_url: "https://ucarecdn.com/#{uuid}/file.jpg", + original_filename: 'file.jpg', + size: 642, + url: "https://api.uploadcare.com/files/#{uuid}/", + uuid: uuid + } + end + before do + stub_request(:put, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('uuid' => uuid) } + it { is_expected.to include('datetime_stored') } + end + + context 'when the request returns an error' do + before do + stub_request(:put, full_url) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequestError' do + expect { client.store(uuid) }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end + + describe '#delete' do + let(:uuid) { SecureRandom.uuid } + let(:path) { "/files/#{uuid}/storage/" } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:removed_date) { Time.now } + + subject { client.delete(uuid) } + + context 'when the request is successful' do + let(:response_body) do + { + datetime_removed: removed_date, + datetime_stored: nil, + datetime_uploaded: '2018-11-26T12:49:09.945335Z', + variations: nil, + is_image: true, + is_ready: true, + mime_type: 'image/jpeg', + original_file_url: "https://ucarecdn.com/#{uuid}/file.jpg", + original_filename: 'file.jpg', + size: 642, + url: "https://api.uploadcare.com/files/#{uuid}/", + uuid: uuid + } + end + before do + stub_request(:delete, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('uuid' => uuid) } + it { is_expected.to include('datetime_removed') } + end + + context 'when the request returns an error' do + before do + stub_request(:delete, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.delete(uuid) }.to raise_error(Uploadcare::NotFoundError, "Bad Request") + end + end + end + + describe '#info' do + let(:uuid) { SecureRandom.uuid } + let(:path) { "/files/#{uuid}/" } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.info(uuid) } + + context 'when the request is successful' do + let(:response_body) do + { + datetime_removed: nil, + datetime_stored: '2018-11-26T12:49:10.477888Z', + datetime_uploaded: '2018-11-26T12:49:09.945335Z', + variations: nil, + is_image: true, + is_ready: true, + mime_type: 'image/jpeg', + original_file_url: "https://ucarecdn.com/#{uuid}/file.jpg", + original_filename: 'file.jpg', + size: 642, + url: "https://api.uploadcare.com/files/#{uuid}/", + uuid: uuid + } + end + before do + stub_request(:get, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('uuid' => uuid) } + it { is_expected.to include('datetime_removed') } + end + + context 'when the request returns an error' do + before do + stub_request(:get, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.info(uuid) }.to raise_error(Uploadcare::NotFoundError, "Bad Request") + end + end + end + + describe '#batch_store' do + let(:uuids) { [SecureRandom.uuid, SecureRandom.uuid] } + let(:path) { '/files/storage/' } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:file_data) { { 'uuid' => SecureRandom.uuid, 'original_filename' => 'file.jpg' } } + + subject { client.batch_store(uuids) } + + context 'when the request is successful' do + let(:response_body) do + { + status: 200, + result: [file_data], + problems: [{ 'some-uuid': 'Missing in the project' }] + } + end + before do + stub_request(:put, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('result') } + it { is_expected.to include({ 'status' => 200 }) } + it { is_expected.to include('problems') } + end + + context 'when the request returns an error' do + before do + stub_request(:put, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.batch_store(uuids) }.to raise_error(Uploadcare::NotFoundError, "Bad Request") + end + end + end + + describe '#batch_delete' do + let(:uuids) { [SecureRandom.uuid, SecureRandom.uuid] } + let(:path) { '/files/storage/' } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:file_data) { { 'uuid' => SecureRandom.uuid, 'original_filename' => 'file.jpg' } } + + subject { client.batch_delete(uuids) } + + context 'when the request is successful' do + let(:response_body) do + { + status: 200, + result: [file_data], + problems: [{ 'some-uuid': 'Missing in the project' }] + } + end + before do + stub_request(:delete, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('result') } + it { is_expected.to include({ 'status' => 200 }) } + it { is_expected.to include('problems') } + end + + context 'when the request returns an error' do + before do + stub_request(:delete, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.batch_delete(uuids) }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end + + describe '#local_copy' do + let(:source) { SecureRandom.uuid } + let(:path) { '/files/local_copy/' } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.local_copy(source) } + + context 'when the request is successful' do + let(:response_body) do + { + type: 'file', + result: { + datetime_removed: nil, + datetime_stored: '2018-11-26T12:49:10.477888Z', + datetime_uploaded: '2018-11-26T12:49:09.945335Z', + variations: nil, + is_image: true, + is_ready: true, + mime_type: 'image/jpeg', + original_file_url: "https://ucarecdn.com/#{source}/file.jpg", + original_filename: 'file.jpg', + size: 642, + url: "https://api.uploadcare.com/files/#{source}/", + uuid: source + } + } + end + before do + stub_request(:post, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include('result') } + it { is_expected.to include({ 'type' => 'file' }) } + it { expect(subject['result']['uuid']).to eq(source) } + end + + context 'when the request returns an error' do + before do + stub_request(:post, full_url) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.local_copy(source) }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end + describe '#remote_copy' do + let(:source) { SecureRandom.uuid } + let(:target) { 's3://mybucket/copied_file.jpg' } + let(:options) { { make_public: true, pattern: '${default}' } } + let(:path) { '/files/remote_copy/' } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.remote_copy(source, target, options) } + + context 'when the request is successful' do + let(:response_body) { { type: 'url', result: 's3_url' } } + before do + stub_request(:post, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + it { is_expected.to include({ 'type' => 'url' }) } + it { expect(subject['result']).to be_a(String) } + end + + context 'when the request returns an error' do + before do + stub_request(:post, full_url) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequest' do + expect { client.remote_copy(source, target, options) }.to raise_error(Uploadcare::BadRequestError, "Bad Request") + end + end + end +end diff --git a/spec/uploadcare/clients/file_metadata_client_spec.rb b/spec/uploadcare/clients/file_metadata_client_spec.rb new file mode 100644 index 00000000..afb5d2c0 --- /dev/null +++ b/spec/uploadcare/clients/file_metadata_client_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::FileMetadataClient do + subject(:client) { described_class.new } + + let(:uuid) { '12345' } + let(:key) { 'custom_key' } + let(:value) { 'custom_value' } + + describe '#index' do + let(:response_body) do + { + 'custom_key1' => 'custom_value1', + 'custom_key2' => 'custom_value2' + } + end + + before do + stub_request(:get, "https://api.uploadcare.com/files/#{uuid}/metadata/") + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the metadata index for the file' do + response = client.index(uuid) + expect(response).to eq(response_body) + end + end + + describe '#show' do + let(:response_body) { 'custom_value' } + + before do + stub_request(:get, "https://api.uploadcare.com/files/#{uuid}/metadata/#{key}/") + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the value of the specified metadata key' do + response = client.show(uuid, key) + expect(response).to eq(response_body) + end + end + + describe '#update' do + let(:response_body) { 'custom_value' } + + before do + stub_request(:put, "https://api.uploadcare.com/files/#{uuid}/metadata/#{key}/") + .with(body: value.to_json) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'updates or creates the metadata key with the specified value' do + response = client.update(uuid, key, value) + expect(response).to eq(response_body) + end + end + + describe '#delete' do + before do + stub_request(:delete, "https://api.uploadcare.com/files/#{uuid}/metadata/#{key}/") + .to_return(status: 204, body: '', headers: { 'Content-Type' => 'application/json' }) + end + + it 'deletes the specified metadata key' do + response = client.delete(uuid, key) + expect(response).to be_nil + end + end +end diff --git a/spec/uploadcare/clients/group_client_sepc.rb b/spec/uploadcare/clients/group_client_sepc.rb new file mode 100644 index 00000000..2c7449a9 --- /dev/null +++ b/spec/uploadcare/clients/group_client_sepc.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::GroupClient do + let(:client) { described_class.new } + let(:rest_api_root) { Uploadcare.configuration.rest_api_root } + + describe '#list' do + let(:path) { '/groups/' } + let(:params) { { 'limit' => 10, 'ordering' => '-datetime_created' } } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.list(params) } + + context 'when the request is successful' do + let(:response_body) do + { + 'next' => nil, + 'previous' => nil, + 'per_page' => 10, + 'results' => [ + { + 'id' => 'group_uuid_1~2', + 'datetime_created' => '2023-11-01T12:49:10.477888Z', + 'files_count' => 2, + 'cdn_url' => 'https://ucarecdn.com/group_uuid_1~2/', + 'url' => "#{rest_api_root}groups/group_uuid_1~2/" + }, + { + 'id' => 'group_uuid_2~3', + 'datetime_created' => '2023-11-02T12:49:10.477888Z', + 'files_count' => 3, + 'cdn_url' => 'https://ucarecdn.com/group_uuid_2~3/', + 'url' => "#{rest_api_root}groups/group_uuid_2~3/" + } + ], + 'total' => 2 + } + end + + before do + stub_request(:get, full_url) + .with(query: params) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it { is_expected.to eq(response_body) } + end + + context 'when the request returns an error' do + before do + stub_request(:get, full_url) + .with(query: params) + .to_return(status: 400, body: { 'detail' => 'Bad Request' }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'raises an InvalidRequestError' do + expect { client.list(params) }.to raise_error(Uploadcare::InvalidRequestError, 'Bad Request') + end + end + end + + describe '#info' do + let(:uuid) { 'group_uuid_1~2' } + let(:path) { "/groups/#{uuid}/" } + let(:full_url) { "#{rest_api_root}#{path}" } + + subject { client.info(uuid) } + + context 'when the request is successful' do + let(:response_body) do + { + 'id' => uuid, + 'datetime_created' => '2023-11-01T12:49:10.477888Z', + 'files_count' => 2, + 'cdn_url' => "https://ucarecdn.com/#{uuid}/", + 'url' => "#{rest_api_root}groups/#{uuid}/", + 'files' => [ + { + 'uuid' => 'file_uuid_1', + 'datetime_uploaded' => '2023-11-01T12:49:09.945335Z', + 'is_image' => true, + 'mime_type' => 'image/jpeg', + 'original_filename' => 'file1.jpg', + 'size' => 12_345 + } + ] + } + end + + before do + stub_request(:get, full_url) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it { is_expected.to eq(response_body) } + end + + context 'when the request returns an error' do + before do + stub_request(:get, full_url) + .to_return(status: 404, body: { 'detail' => 'Not Found' }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'raises a NotFoundError' do + expect { client.info(uuid) }.to raise_error(Uploadcare::NotFoundError, 'Not Found') + end + end + end +end diff --git a/spec/uploadcare/clients/multipart_upload_client_spec.rb b/spec/uploadcare/clients/multipart_upload_client_spec.rb new file mode 100644 index 00000000..a9749f01 --- /dev/null +++ b/spec/uploadcare/clients/multipart_upload_client_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::MultipartUploadClient do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'test_public_key', + secret_key: 'test_secret_key' + ) + end + + subject(:client) { described_class.new(config) } + + describe '#start' do + let(:filename) { 'test_file.bin' } + let(:size) { 10 * 1024 * 1024 } # 10MB + let(:mock_response) do + { + 'uuid' => 'upload-uuid-123', + 'parts' => [ + { + 'url' => 'https://s3.amazonaws.com/bucket/part1', + 'start_offset' => 0, + 'end_offset' => 5_242_880 + }, + { + 'url' => 'https://s3.amazonaws.com/bucket/part2', + 'start_offset' => 5_242_880, + 'end_offset' => 10_485_760 + } + ] + } + end + + it 'starts multipart upload' do + stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .with( + body: hash_including( + 'filename' => filename, + 'size' => size.to_s, + 'content_type' => 'application/octet-stream', + 'UPLOADCARE_STORE' => 'auto', + 'pub_key' => 'test_public_key' + ) + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.start(filename, size) + expect(result).to eq(mock_response) + expect(result['uuid']).to eq('upload-uuid-123') + expect(result['parts']).to be_an(Array) + end + + it 'includes metadata in request' do + stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .with( + body: hash_including( + 'metadata[key1]' => 'value1', + 'metadata[key2]' => 'value2' + ) + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.start(filename, size, 'application/octet-stream', metadata: { key1: 'value1', key2: 'value2' }) + end + + it 'respects store option' do + stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .with( + body: hash_including('UPLOADCARE_STORE' => '1') + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.start(filename, size, 'application/octet-stream', store: 1) + end + end + + describe '#complete' do + let(:uuid) { 'upload-uuid-123' } + let(:mock_response) { { 'uuid' => uuid, 'file' => 'file-uuid-456' } } + + it 'completes multipart upload' do + stub_request(:post, 'https://upload.uploadcare.com/multipart/complete/') + .with( + body: hash_including('uuid' => uuid, 'pub_key' => 'test_public_key') + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.complete(uuid) + expect(result).to eq(mock_response) + end + end + + describe '#upload_chunk' do + let(:file_path) { File.join(File.dirname(__FILE__), '../../fixtures/big.jpeg') } + let(:upload_data) do + { + 'parts' => [ + { + 'url' => 'https://s3.amazonaws.com/bucket/part1', + 'start_offset' => 0, + 'end_offset' => 1000 + } + ] + } + end + + it 'uploads chunks to S3' do + stub_request(:put, 'https://s3.amazonaws.com/bucket/part1') + .with( + headers: { 'Content-Type' => 'application/octet-stream' } + ) + .to_return(status: 200) + + expect { client.upload_chunk(file_path, upload_data) }.not_to raise_error + end + + it 'raises error on failed chunk upload' do + stub_request(:put, 'https://s3.amazonaws.com/bucket/part1') + .to_return(status: 403) + + expect { client.upload_chunk(file_path, upload_data) } + .to raise_error(Uploadcare::RequestError, /Failed to upload chunk: 403/) + end + end + + describe '#upload_file' do + let(:file_path) { File.join(File.dirname(__FILE__), '../../fixtures/big.jpeg') } + let(:file_size) { File.size(file_path) } + let(:start_response) do + { + 'uuid' => 'upload-uuid-123', + 'parts' => [ + { + 'url' => 'https://s3.amazonaws.com/bucket/part1', + 'start_offset' => 0, + 'end_offset' => file_size + } + ] + } + end + let(:complete_response) { { 'uuid' => 'upload-uuid-123', 'file' => 'file-uuid-456' } } + + it 'performs full multipart upload flow' do + # Stub start request + stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .to_return(status: 200, body: start_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + # Stub S3 upload + stub_request(:put, 'https://s3.amazonaws.com/bucket/part1') + .to_return(status: 200) + + # Stub complete request + stub_request(:post, 'https://upload.uploadcare.com/multipart/complete/') + .to_return(status: 200, body: complete_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.upload_file(file_path) + expect(result).to eq(complete_response) + end + + it 'uses custom filename if provided' do + stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .with( + body: hash_including('filename' => 'custom_name.jpg') + ) + .to_return(status: 200, body: start_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + stub_request(:put, 'https://s3.amazonaws.com/bucket/part1') + .to_return(status: 200) + + stub_request(:post, 'https://upload.uploadcare.com/multipart/complete/') + .to_return(status: 200, body: complete_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.upload_file(file_path, filename: 'custom_name.jpg') + end + end + + describe 'CHUNK_SIZE constant' do + it 'is set to 5MB' do + expect(described_class::CHUNK_SIZE).to eq(5 * 1024 * 1024) + end + end +end diff --git a/spec/uploadcare/clients/project_client_spec.rb b/spec/uploadcare/clients/project_client_spec.rb new file mode 100644 index 00000000..ba6a3afc --- /dev/null +++ b/spec/uploadcare/clients/project_client_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::ProjectClient do + subject(:client) { described_class.new } + + describe '#show' do + let(:response_body) do + { + 'name' => 'My Project', + 'pub_key' => 'project_public_key', + 'collaborators' => [ + { + 'email' => 'admin@example.com', + 'name' => 'Admin' + } + ] + } + end + + before do + stub_request(:get, 'https://api.uploadcare.com/project/') + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns the project details' do + response = client.show + expect(response).to eq(response_body) + end + end +end diff --git a/spec/uploadcare/clients/rest_client_spec.rb b/spec/uploadcare/clients/rest_client_spec.rb new file mode 100644 index 00000000..2e9961ba --- /dev/null +++ b/spec/uploadcare/clients/rest_client_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::RestClient do + describe '#get' do + let(:path) { '/test_endpoint/' } + let(:params) { { 'param1' => 'value1', 'param2' => 'value2' } } + let(:headers) { { 'Custom-Header' => 'HeaderValue' } } + let(:full_url) { "#{Uploadcare.configuration.rest_api_root}#{path}" } + + context 'when the request is successful' do + let(:response_body) { { 'key' => 'value' } } + + before do + stub_request(:get, full_url) + .with( + query: params + ) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the response body parsed as JSON' do + response = subject.get(path, params, headers) + expect(response).to eq(response_body) + end + end + + context 'when the request returns a 400 Bad Request' do + before do + stub_request(:get, full_url) + .with(query: params) + .to_return( + status: 400, + body: { 'detail' => 'Bad Request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequestError' do + expect { subject.get(path, params, headers) }.to raise_error(Uploadcare::BadRequestError, 'Bad Request') + end + end + + context 'when the request returns a 401 Unauthorized' do + before do + stub_request(:get, full_url) + .to_return( + status: 401, + body: { 'detail' => 'Unauthorized' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an AuthenticationError' do + expect { subject.get(path) }.to raise_error(Uploadcare::AuthenticationError, 'Unauthorized') + end + end + + context 'when the request returns a 403 Forbidden' do + before do + stub_request(:get, full_url) + .to_return( + status: 403, + body: { 'detail' => 'Forbidden' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an AuthorizationError' do + expect { subject.get(path) }.to raise_error(Uploadcare::ForbiddenError, 'Forbidden') + end + end + + context 'when the request returns a 404 Not Found' do + before do + stub_request(:get, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises a NotFoundError' do + expect { subject.get(path) }.to raise_error(Uploadcare::NotFoundError, 'Not Found') + end + end + + context 'when the request fails with an unexpected error' do + before do + stub_request(:get, full_url) + .to_raise(Faraday::Error) + end + + it 'raises an Uploadcare::Error' do + expect { subject.get(path) }.to raise_error(Uploadcare::Error) + end + end + end +end diff --git a/spec/uploadcare/clients/upload_client_spec.rb b/spec/uploadcare/clients/upload_client_spec.rb new file mode 100644 index 00000000..16a9125c --- /dev/null +++ b/spec/uploadcare/clients/upload_client_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::UploadClient do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'test_public_key', + secret_key: 'test_secret_key' + ) + end + + subject(:client) { described_class.new(config) } + + describe '#initialize' do + it 'initializes with configuration' do + expect(client).to be_a(described_class) + end + + it 'uses default configuration when none provided' do + client = described_class.new + expect(client).to be_a(described_class) + end + end + + describe 'private methods' do + describe '#connection' do + it 'creates a Faraday connection with correct base URL' do + connection = client.send(:connection) + expect(connection).to be_a(Faraday::Connection) + expect(connection.url_prefix.to_s).to eq('https://upload.uploadcare.com/') + end + + it 'configures multipart and json handling' do + connection = client.send(:connection) + expect(connection.builder.handlers).to include(Faraday::Request::Multipart) + expect(connection.builder.handlers).to include(Faraday::Request::UrlEncoded) + end + end + + describe '#execute_request' do + let(:connection) { instance_double(Faraday::Connection) } + let(:response) { instance_double(Faraday::Response, success?: true, body: { 'result' => 'success' }) } + + before do + allow(client).to receive(:connection).and_return(connection) + end + + it 'adds public key to params' do + expect(connection).to receive(:get).with('/test', hash_including(pub_key: 'test_public_key'), anything).and_return(response) + client.send(:execute_request, :get, '/test') + end + + it 'adds user agent header' do + expect(connection).to receive(:get).with('/test', anything, hash_including('User-Agent' => /Uploadcare Ruby/)).and_return(response) + client.send(:execute_request, :get, '/test') + end + + context 'when request succeeds' do + it 'returns response body' do + allow(connection).to receive(:get).and_return(response) + result = client.send(:execute_request, :get, '/test') + expect(result).to eq({ 'result' => 'success' }) + end + end + + context 'when request fails' do + let(:failed_response) { instance_double(Faraday::Response, success?: false, status: 400, body: { 'error' => 'Bad request' }) } + + it 'raises RequestError' do + allow(connection).to receive(:get).and_return(failed_response) + expect { client.send(:execute_request, :get, '/test') }.to raise_error(Uploadcare::RequestError, 'Bad request') + end + end + + context 'when Faraday error occurs' do + it 'handles connection errors' do + allow(connection).to receive(:get).and_raise(Faraday::ConnectionFailed.new('Connection failed')) + expect { client.send(:execute_request, :get, '/test') }.to raise_error(Uploadcare::RequestError, /Request failed: Connection failed/) + end + end + end + + describe '#user_agent' do + it 'returns proper user agent string' do + user_agent = client.send(:user_agent) + expect(user_agent).to match(%r{Uploadcare Ruby/\d+\.\d+\.\d+ \(Ruby/\d+\.\d+\.\d+\)}) + end + end + end +end diff --git a/spec/uploadcare/clients/uploader_client_spec.rb b/spec/uploadcare/clients/uploader_client_spec.rb new file mode 100644 index 00000000..2e171e41 --- /dev/null +++ b/spec/uploadcare/clients/uploader_client_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::UploaderClient do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'test_public_key', + secret_key: 'test_secret_key' + ) + end + + subject(:client) { described_class.new(config) } + + describe '#upload_file' do + let(:file_path) { File.join(File.dirname(__FILE__), '../../fixtures/kitten.jpeg') } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads a file successfully' do + stub_request(:post, 'https://upload.uploadcare.com/base/') + .with( + body: /Content-Disposition: form-data/, + headers: { 'User-Agent' => /Uploadcare Ruby/ } + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.upload_file(file_path) + expect(result).to eq(mock_response) + end + + it 'includes upload options in request' do + stub_request(:post, 'https://upload.uploadcare.com/base/') + .with do |request| + request.body.include?('store') && + request.body.include?('1') && + request.body.include?('filename') && + request.body.include?('test.jpg') + end + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.upload_file(file_path, store: 1, filename: 'test.jpg') + end + + it 'includes metadata in request' do + stub_request(:post, 'https://upload.uploadcare.com/base/') + .with do |request| + request.body.include?('metadata[key1]') && + request.body.include?('value1') + end + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.upload_file(file_path, metadata: { key1: 'value1' }) + end + end + + describe '#upload_files' do + let(:file_paths) do + [ + File.join(File.dirname(__FILE__), '../../fixtures/kitten.jpeg'), + File.join(File.dirname(__FILE__), '../../fixtures/another_kitten.jpeg') + ] + end + + it 'uploads multiple files' do + stub_request(:post, 'https://upload.uploadcare.com/base/') + .to_return(status: 200, body: { 'file' => 'file-uuid-123' }.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.upload_files(file_paths) + expect(result[:files]).to be_an(Array) + expect(result[:files].size).to eq(2) + end + end + + describe '#upload_from_url' do + let(:url) { 'https://example.com/image.jpg' } + + context 'synchronous upload' do + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads from URL successfully' do + stub_request(:post, 'https://upload.uploadcare.com/from_url/') + .with( + body: hash_including('source_url' => url, 'pub_key' => 'test_public_key') + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.upload_from_url(url) + expect(result).to eq(mock_response) + end + end + + context 'asynchronous upload' do + let(:mock_response) { { 'token' => 'upload-token-123' } } + + it 'returns upload token for async upload' do + stub_request(:post, 'https://upload.uploadcare.com/from_url/') + .with( + body: hash_including('source_url' => url) + ) + .to_return(status: 200, body: mock_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = client.upload_from_url(url) + expect(result['token']).to eq('upload-token-123') + end + end + + it 'includes options in request' do + stub_request(:post, 'https://upload.uploadcare.com/from_url/') + .with( + body: hash_including( + 'source_url' => url, + 'check_URL_duplicates' => '1', + 'save_URL_duplicates' => '0' + ) + ) + .to_return(status: 200, body: {}.to_json, headers: { 'Content-Type' => 'application/json' }) + + client.upload_from_url(url, check_duplicates: 1, save_duplicates: 0) + end + end + + describe '#check_upload_status' do + let(:token) { 'upload-token-123' } + + it 'checks upload status' do + stub_request(:get, 'https://upload.uploadcare.com/from_url/status/') + .with(query: hash_including('token' => token)) + .to_return( + status: 200, + body: { 'status' => 'success', 'file' => 'file-uuid-123' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = client.check_upload_status(token) + expect(result['status']).to eq('success') + expect(result['file']).to eq('file-uuid-123') + end + end + + describe '#file_info' do + let(:uuid) { 'file-uuid-123' } + + it 'retrieves file info' do + stub_request(:get, 'https://upload.uploadcare.com/info/') + .with(query: hash_including('file_id' => uuid)) + .to_return( + status: 200, + body: { 'uuid' => uuid, 'size' => 12_345 }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = client.file_info(uuid) + expect(result['uuid']).to eq(uuid) + expect(result['size']).to eq(12_345) + end + end + + describe '#build_upload_params' do + it 'builds correct parameters' do + options = { + store: 1, + filename: 'test.jpg', + check_duplicates: true, + save_duplicates: false, + metadata: { key1: 'value1', key2: 'value2' } + } + + params = client.send(:build_upload_params, options) + + expect(params[:store]).to eq(1) + expect(params[:filename]).to eq('test.jpg') + expect(params[:check_URL_duplicates]).to eq(true) + expect(params[:save_URL_duplicates]).to eq(false) + expect(params['metadata[key1]']).to eq('value1') + expect(params['metadata[key2]']).to eq('value2') + end + end +end diff --git a/spec/uploadcare/clients/video_converter_client_spec.rb b/spec/uploadcare/clients/video_converter_client_spec.rb new file mode 100644 index 00000000..ef9320fd --- /dev/null +++ b/spec/uploadcare/clients/video_converter_client_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::VideoConverterClient do + let(:client) { described_class.new } + let(:rest_api_root) { Uploadcare.configuration.rest_api_root } + let(:uuid) { SecureRandom.uuid } + let(:token) { 32_921_143 } + + describe '#convert_video' do + let(:path) { '/convert/video/' } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:video_paths) { ["#{uuid}/video/-/format/mp4/-/quality/lighter/"] } + let(:options) { { store: '1' } } + let(:request_body) do + { + paths: video_paths, + store: options[:store] + } + end + let(:response_body) do + { + 'problems' => {}, + 'result' => [ + { + 'original_source' => "#{uuid}/video/-/format/mp4/-/quality/lighter/", + 'token' => 445_630_631, + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d', + 'thumbnails_group_uuid' => '575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1' + } + ] + } + end + + subject { client.convert_video(video_paths, options) } + + context 'when the request is successful' do + before do + stub_request(:post, full_url) + .with(body: request_body.to_json) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'sends the correct request' do + expect(subject).to eq(response_body) + end + + it 'returns conversion details' do + result = subject['result'].first + expect(result['uuid']).to eq('d52d7136-a2e5-4338-9f45-affbf83b857d') + expect(result['token']).to eq(445_630_631) + expect(result['thumbnails_group_uuid']).to eq('575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1') + end + end + + context 'when the request fails' do + before do + stub_request(:post, full_url) + .with(body: request_body.to_json) + .to_return( + status: 400, + body: { 'detail' => 'Invalid request' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises an InvalidRequestError' do + expect { subject }.to raise_error(Uploadcare::RequestError, 'Invalid request') + end + end + end + + describe '#status' do + let(:path) { "/convert/video/status/#{token}/" } + let(:full_url) { "#{rest_api_root}#{path}" } + let(:response_body) do + { + 'status' => 'processing', + 'error' => nil, + 'result' => { + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d', + 'thumbnails_group_uuid' => '575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1' + } + } + end + + subject { client.status(token) } + + context 'when the request is successful' do + before do + stub_request(:get, full_url) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns the job status' do + expect(subject['status']).to eq('processing') + expect(subject['result']['uuid']).to eq('d52d7136-a2e5-4338-9f45-affbf83b857d') + expect(subject['result']['thumbnails_group_uuid']).to eq('575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1') + end + end + + context 'when the request fails' do + before do + stub_request(:get, full_url) + .to_return( + status: 404, + body: { 'detail' => 'Job not found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises a NotFoundError' do + expect { subject }.to raise_error(Uploadcare::RequestError, 'Job not found') + end + end + end +end diff --git a/spec/uploadcare/clients/webhook_client_spec.rb b/spec/uploadcare/clients/webhook_client_spec.rb new file mode 100644 index 00000000..5b49ede9 --- /dev/null +++ b/spec/uploadcare/clients/webhook_client_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +RSpec.describe Uploadcare::WebhookClient do + subject(:webhook_client) { described_class.new } + + describe '#list_webhooks' do + let(:response_body) do + [ + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => 'file.infected', + 'target_url' => 'http://example.com/hooks/receiver', + 'is_active' => true, + 'signing_secret' => '7kMVZivndx0ErgvhRKAr', + 'version' => '0.7' + } + ] + end + + before do + stub_request(:get, 'https://api.uploadcare.com/webhooks/') + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns a list of webhooks' do + response = webhook_client.list_webhooks + expect(response).to eq(response_body) + end + end + + describe '#create_webhook' do + let(:target_url) { 'https://example.com/hooks' } + let(:event) { 'file.uploaded' } + let(:is_active) { true } + let(:signing_secret) { 'secret' } + let(:version) { '0.7' } + let(:payload) do + { + target_url: target_url, + event: event, + is_active: is_active, + signing_secret: signing_secret, + version: version + } + end + + let(:response_body) do + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => event, + 'target_url' => target_url, + 'is_active' => is_active, + 'signing_secret' => signing_secret, + 'version' => version + } + end + + before do + stub_request(:post, 'https://api.uploadcare.com/webhooks/') + .with(body: payload) + .to_return( + status: 201, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'creates a new webhook' do + response = webhook_client.create_webhook( + target_url, + event, + is_active, + signing_secret, + version + ) + expect(response).to eq(response_body) + end + end + describe '#update_webhook' do + let(:webhook_id) { 1 } + let(:payload) do + { + target_url: 'https://example.com/hooks/updated', + event: 'file.uploaded', + is_active: true, + signing_secret: 'updated-secret' + } + end + + let(:response_body) do + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => 'file.uploaded', + 'target_url' => 'https://example.com/hooks/updated', + 'is_active' => true, + 'signing_secret' => 'updated-secret', + 'version' => '0.7' + } + end + + before do + stub_request(:put, "https://api.uploadcare.com/webhooks/#{webhook_id}/") + .with(body: payload) + .to_return( + status: 200, + body: response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'updates the webhook and returns the updated attributes' do + response = webhook_client.update_webhook( + webhook_id, + 'https://example.com/hooks/updated', + 'file.uploaded', + is_active: true, + signing_secret: 'updated-secret' + ) + expect(response).to eq(response_body) + end + end + + describe '#delete_webhook' do + let(:target_url) { 'http://example.com' } + + before do + stub_request(:delete, 'https://api.uploadcare.com/webhooks/unsubscribe/') + .with(body: { target_url: target_url }) + .to_return(status: 204) + end + + it 'deletes the webhook successfully' do + VCR.use_cassette('rest_webhook_destroy') do + expect { subject.delete_webhook(target_url) }.not_to raise_error + end + end + end +end diff --git a/spec/uploadcare/cname_generator_spec.rb b/spec/uploadcare/cname_generator_spec.rb new file mode 100644 index 00000000..c271c4c9 --- /dev/null +++ b/spec/uploadcare/cname_generator_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::CnameGenerator do + describe '.generate' do + it 'generates consistent subdomain from public key' do + subdomain = described_class.generate('demopublickey') + expect(subdomain).to eq('0fed487a8a') + expect(subdomain.length).to eq(10) + end + + it 'returns same subdomain for same key' do + key = 'test_public_key' + subdomain1 = described_class.generate(key) + subdomain2 = described_class.generate(key) + + expect(subdomain1).to eq(subdomain2) + end + + it 'returns different subdomains for different keys' do + subdomain1 = described_class.generate('key1') + subdomain2 = described_class.generate('key2') + + expect(subdomain1).not_to eq(subdomain2) + end + + it 'returns nil for nil public key' do + expect(described_class.generate(nil)).to be_nil + end + + it 'returns nil for empty public key' do + expect(described_class.generate('')).to be_nil + end + end + + describe '.cdn_base_url' do + let(:public_key) { 'demopublickey' } + let(:cdn_base_postfix) { 'https://ucarecd.net/' } + + it 'generates subdomain-based CDN URL' do + url = described_class.cdn_base_url(public_key, cdn_base_postfix) + expect(url).to eq('https://0fed487a8a.ucarecd.net/') + end + + it 'preserves path in CDN base postfix' do + cdn_base = 'https://cdn.example.com/path/' + url = described_class.cdn_base_url(public_key, cdn_base) + expect(url).to match(%r{https://[a-z0-9]+\.cdn\.example\.com/path/}) + end + + it 'returns original CDN base when public key is nil' do + url = described_class.cdn_base_url(nil, cdn_base_postfix) + expect(url).to eq(cdn_base_postfix) + end + + it 'returns original CDN base when public key is empty' do + url = described_class.cdn_base_url('', cdn_base_postfix) + expect(url).to eq(cdn_base_postfix) + end + + it 'handles CDN base without trailing slash' do + cdn_base = 'https://ucarecd.net' + url = described_class.cdn_base_url(public_key, cdn_base) + expect(url).to eq('https://0fed487a8a.ucarecd.net') + end + end +end diff --git a/spec/uploadcare/concerns/throttle_handler_spec.rb b/spec/uploadcare/concerns/throttle_handler_spec.rb index ec53210e..8d791c18 100644 --- a/spec/uploadcare/concerns/throttle_handler_spec.rb +++ b/spec/uploadcare/concerns/throttle_handler_spec.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true require 'spec_helper' -require 'uploadcare/concern/throttle_handler' module Uploadcare - RSpec.describe Concerns::ThrottleHandler do - include Concerns::ThrottleHandler + RSpec.describe ThrottleHandler do + include ThrottleHandler def sleep(_time); end before { @called = 0 } @@ -13,7 +12,7 @@ def sleep(_time); end let(:throttler) do lambda do @called += 1 - raise ThrottleError if @called < 3 + raise Uploadcare::ThrottleError if @called < 3 "Throttler has been called #{@called} times" end diff --git a/spec/uploadcare/configuration_spec.rb b/spec/uploadcare/configuration_spec.rb new file mode 100644 index 00000000..ff651338 --- /dev/null +++ b/spec/uploadcare/configuration_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'logger' + +RSpec.describe Uploadcare::Configuration do + subject(:config) { described_class.new } + let(:default_values) do + { public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY', ''), + secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY', ''), + auth_type: 'Uploadcare', + multipart_size_threshold: 100 * 1024 * 1024, + rest_api_root: 'https://api.uploadcare.com', + upload_api_root: 'https://upload.uploadcare.com', + max_request_tries: 100, + base_request_sleep: 1, + max_request_sleep: 60.0, + sign_uploads: false, + upload_signature_lifetime: 30 * 60, + max_throttle_attempts: 5, + upload_threads: 2, + framework_data: '', + file_chunk_size: 100 } + end + let(:new_values) do + { + public_key: 'test_public_key', + secret_key: 'test_secret_key', + auth_type: 'Uploadcare.Simple', + multipart_size_threshold: 50 * 1024 * 1024, + rest_api_root: 'https://api.example.com', + upload_api_root: 'https://upload.example.com', + max_request_tries: 5, + base_request_sleep: 2, + max_request_sleep: 30.0, + sign_uploads: true, + upload_signature_lifetime: 60 * 60, + max_throttle_attempts: 10, + upload_threads: 4, + framework_data: 'Rails/6.0.0', + file_chunk_size: 200 + } + end + + it 'has configurable default values' do + default_values.each do |attribute, expected_value| + actual_value = config.send(attribute) + if expected_value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher) + expect(actual_value).to expected_value + else + expect(actual_value).to eq(expected_value) + end + end + + new_values.each do |attribute, new_value| + config.send("#{attribute}=", new_value) + expect(config.send(attribute)).to eq(new_value) + end + end +end diff --git a/spec/uploadcare/entity/addons_spec.rb b/spec/uploadcare/entity/addons_spec.rb deleted file mode 100644 index db74dcd9..00000000 --- a/spec/uploadcare/entity/addons_spec.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe Addons do - subject { Addons } - - it 'responds to expected methods' do - methods = %i[uc_clamav_virus_scan uc_clamav_virus_scan_status ws_rekognition_detect_labels - ws_rekognition_detect_labels_status remove_bg remove_bg_status - ws_rekognition_detect_moderation_labels ws_rekognition_detect_moderation_labels_status] - expect(subject).to respond_to(*methods) - end - - describe 'uc_clamav_virus_scan' do - it 'scans the file for viruses' do - VCR.use_cassette('uc_clamav_virus_scan') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - params = { purge_infected: false } - response = subject.uc_clamav_virus_scan(uuid, params) - expect(response.request_id).to eq('34abf037-5384-4e38-bad4-97dd48e79acd') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('uc_clamav_virus_scan_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'uc_clamav_virus_scan_status' do - it 'checking the status of a virus scanned file' do - VCR.use_cassette('uc_clamav_virus_scan_status') do - uuid = '34abf037-5384-4e38-bad4-97dd48e79acd' - response = subject.uc_clamav_virus_scan_status(uuid) - expect(response.status).to eq('done') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('uc_clamav_virus_scan_status_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan_status(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'ws_rekognition_detect_labels' do - it 'executes aws rekognition' do - VCR.use_cassette('ws_rekognition_detect_labels') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - response = subject.ws_rekognition_detect_labels(uuid) - expect(response.request_id).to eq('0f4598dd-d168-4272-b49e-e7f9d2543542') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('ws_rekognition_detect_labels_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan_status(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'ws_rekognition_detect_labels_status' do - it 'checking the status of a recognized file' do - VCR.use_cassette('ws_rekognition_detect_labels_status') do - uuid = '0f4598dd-d168-4272-b49e-e7f9d2543542' - response = subject.ws_rekognition_detect_labels_status(uuid) - expect(response.status).to eq('done') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('ws_rekognition_detect_labels_status_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan_status(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'remove_bg' do - it 'executes background image removal' do - VCR.use_cassette('remove_bg') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - params = { crop: true, type_level: '2' } - response = subject.remove_bg(uuid, params) - expect(response.request_id).to eq('c3446e41-9eb0-4301-aeb4-356d0fdcf9af') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('remove_bg_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan_status(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'remove_bg_status' do - it 'checking the status background image removal file' do - VCR.use_cassette('remove_bg_status') do - uuid = 'c3446e41-9eb0-4301-aeb4-356d0fdcf9af' - response = subject.remove_bg_status(uuid) - expect(response.status).to eq('done') - expect(response.result).to eq({ 'file_id' => 'bc37b996-916d-4ed7-b230-fa71a4290cb3' }) - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('remove_bg_status_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.uc_clamav_virus_scan_status(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'ws_rekognition_detect_moderation_labels' do - it 'executes aws rekognition detect moderation' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels') do - uuid = 'ff4d3d37-4de0-4f6d-a7db-8cdabe7fc768' - response = subject.ws_rekognition_detect_moderation_labels(uuid) - expect(response.request_id).to eq('0f4598dd-d168-4272-b49e-e7f9d2543542') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.ws_rekognition_detect_moderation_labels(uuid) }.to raise_error(RequestError) - end - end - end - - describe 'ws_rekognition_detect_moderation_labels_status' do - it 'checking the status of a recognized file' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels_status') do - uuid = '0f4598dd-d168-4272-b49e-e7f9d2543542' - response = subject.ws_rekognition_detect_moderation_labels_status(uuid) - expect(response.status).to eq('done') - end - end - - it 'raises error for nonexistent file uuid' do - VCR.use_cassette('ws_rekognition_detect_moderation_labels_status_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.ws_rekognition_detect_moderation_labels_status(uuid) }.to raise_error(RequestError) - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/conversion/document_converter_spec.rb b/spec/uploadcare/entity/conversion/document_converter_spec.rb deleted file mode 100644 index ef507cb9..00000000 --- a/spec/uploadcare/entity/conversion/document_converter_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - module Conversion - RSpec.describe DocumentConverter do - subject { Uploadcare::DocumentConverter } - - describe 'convert' do - shared_examples 'converts documents' do |multipage: false, group: false| - it 'returns a result with document data', :aggregate_failures do - response_value = subject.convert(params, **options).success - result = response_value[:result].first - - expect(response_value[:problems]).to be_empty - expect(result[:uuid]).not_to be_nil - - [doc_params[:uuid], :format].each do |param| - expect(result[:original_source]).to match(param.to_s) - end - expect(result[:original_source]).to match('page') if doc_params[:page] - - next unless multipage - - info_response_values = subject.info(doc_params[:uuid]) # get info about that document - if group - expect( - info_response_values.success.dig(:format, :converted_groups, doc_params[:format].to_sym) - ).not_to be_empty - else - expect(info_response_values.success.dig(:format, :converted_groups)).to be_nil - end - end - end - - let(:doc_params) do - { - uuid: 'a4b9db2f-1591-4f4c-8f68-94018924525d', - format: 'png', - page: 1 - } - end - let(:options) { { store: false } } - - context 'when sending params as an Array', vcr: 'document_convert_convert_many' do - let(:params) { [doc_params] } - - it_behaves_like 'converts documents' - end - - context 'when sending params as a Hash', vcr: 'document_convert_convert_many' do - let(:params) { doc_params } - - it_behaves_like 'converts documents' - end - - # Ref: https://uploadcare.com/docs/transformations/document-conversion/#multipage-conversion - describe 'multipage conversion' do - context 'when not saved in group', vcr: 'document_convert_convert_multipage_zip' do - let(:doc_params) do - { - uuid: 'd95309eb-50bd-4594-bd7a-950011578480', - format: 'jpg' - } - end - let(:options) { { store: '1', save_in_group: '0' } } - let(:params) { doc_params } - - it_behaves_like 'converts documents', { multipage: true, group: false } - end - - context 'when saved in group', vcr: 'document_convert_convert_multipage_group' do - let(:doc_params) do - { - uuid: '23d29586-713e-4152-b400-05fb54730453', - format: 'jpg' - } - end - let(:options) { { store: '0', save_in_group: '1' } } - let(:params) { doc_params } - - it_behaves_like 'converts documents', { multipage: true, group: true } - end - end - end - - describe 'get document conversion status' do - let(:token) { '21120333' } - - it 'returns a document conversion status data', :aggregate_failures do - VCR.use_cassette('document_convert_get_status') do - response_value = subject.status(token).success - - expect(response_value[:status]).to eq 'finished' - expect(response_value[:error]).to be_nil - expect(response_value[:result].keys).to contain_exactly(:uuid) - end - end - end - - describe 'info' do - it 'shows info about that document' do - VCR.use_cassette('document_convert_info') do - uuid = 'cd7a51d4-9776-4749-b749-c9fc691891f1' - response = subject.info(uuid) - expect(response.value!.key?(:format)).to be_truthy - document_formats = response.value![:format] - expect(document_formats.key?(:conversion_formats)).to be_truthy - end - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/conversion/video_converter_spec.rb b/spec/uploadcare/entity/conversion/video_converter_spec.rb deleted file mode 100644 index bfb6a03d..00000000 --- a/spec/uploadcare/entity/conversion/video_converter_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - module Conversion - RSpec.describe VideoConverter do - subject { Uploadcare::VideoConverter } - - describe 'successfull conversion' do - describe 'convert_many' do - shared_examples 'converts videos' do - it 'returns a result with video data', :aggregate_failures do - VCR.use_cassette('video_convert_convert_many') do - response_value = subject.convert(array_of_params, **options).success - result = response_value[:result].first - - expect(response_value[:problems]).to be_empty - expect(result[:uuid]).not_to be_nil - - [video_params[:uuid], :size, :quality, :format, :cut, :thumbs].each do |param| - expect(result[:original_source]).to match(param.to_s) - end - end - end - end - - let(:array_of_params) { [video_params] } - let(:video_params) do - { - uuid: 'e30112d7-3a90-4931-b2c5-688cbb46d3ac', - size: { resize_mode: 'change_ratio', width: '600', height: '400' }, - quality: 'best', - format: 'ogg', - cut: { start_time: '0:0:0.0', length: '0:0:1.0' }, - thumbs: { thumbs_n: 2, number: 1 } - } - end - let(:options) { { store: false } } - - context 'when all params are present' do - it_behaves_like 'converts videos' - end - - %i[size quality format cut thumbs].each do |param| - context "when only :#{param} param is present" do - let(:arguments) { super().select { |k, _v| [:uuid, param].include?(k) } } - - it_behaves_like 'converts videos' - end - end - end - - describe 'get video conversion status' do - let(:token) { '911933811' } - - it 'returns a video conversion status data', :aggregate_failures do - VCR.use_cassette('video_convert_get_status') do - response_value = subject.status(token).success - - expect(response_value[:status]).to eq 'finished' - expect(response_value[:error]).to be_nil - expect(response_value[:result].keys).to contain_exactly(:uuid, :thumbnails_group_uuid) - end - end - end - end - - describe 'conversion with error' do - shared_examples 'requesting video conversion' do - it 'raises a conversion error' do - VCR.use_cassette('video_convert_convert_many_with_error') do - expect(subject).to be_failure - end - end - end - - describe 'convert_many' do - subject { described_class.convert(array_of_params, **options) } - - let(:array_of_params) do - [ - { - uuid: 'e30112d7-3a90-4931-b2c5-688cbb46d3ac', - size: { resize_mode: 'change_ratio' }, - quality: 'best', - format: 'ogg', - cut: { start_time: '0:0:0.0', length: '0:0:1.0' }, - thumbs: { N: 2, number: 1 } - } - ] - end - let(:options) { { store: false } } - - context 'when no width and height are provided' do - let(:message) { /CDN Path error/ } - - it_behaves_like 'requesting video conversion' - end - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/decorator/paginator_spec.rb b/spec/uploadcare/entity/decorator/paginator_spec.rb deleted file mode 100644 index 281ef4b1..00000000 --- a/spec/uploadcare/entity/decorator/paginator_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - module Decorator - RSpec.describe Paginator do - describe 'meta' do - it 'accepts arguments' do - VCR.use_cassette('rest_file_list_params') do - fl_with_params = FileList.file_list(limit: 2, ordering: '-datetime_uploaded') - expect(fl_with_params.meta.per_page).to eq 2 - end - end - end - - describe 'next_page' do - it 'loads a next page as separate object' do - VCR.use_cassette('rest_file_list_pages') do - fl_with_params = FileList.file_list(limit: 2, ordering: '-datetime_uploaded') - next_page = fl_with_params.next_page - expect(next_page.previous).not_to be_nil - expect(fl_with_params).not_to eq(next_page) - end - end - end - - describe 'previous_page' do - it 'loads a previous page as separate object' do - VCR.use_cassette('rest_file_list_previous_page') do - fl_with_params = FileList.file_list(limit: 2, ordering: '-datetime_uploaded') - next_page = fl_with_params.next_page - previous_page = next_page.previous_page - expect(previous_page.next).not_to be_nil - fl_path = fl_with_params.delete(:next) - previous_page_path = previous_page.delete(:next) - expect(fl_with_params).to eq(previous_page) - expect(CGI.parse(URI.parse(fl_path).query)).to eq(CGI.parse(URI.parse(previous_page_path).query)) - end - end - end - - describe 'load' do - it 'loads all objects' do - VCR.use_cassette('rest_file_list_load') do - fl_with_params = FileList.file_list(limit: 2, ordering: '-datetime_uploaded') - fl_with_params.load - expect(fl_with_params.results.length).to eq fl_with_params.total - end - end - end - - describe 'each' do - it 'iterates each file in list' do - VCR.use_cassette('rest_file_list_each') do - fl_with_params = FileList.file_list(limit: 2) - # rubocop:disable Style/MapIntoArray - entities = [] - fl_with_params.each do |file| - entities << file - end - # rubocop:enable Style/MapIntoArray - expect(entities.length).to eq fl_with_params.total - end - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/file_list_spec.rb b/spec/uploadcare/entity/file_list_spec.rb deleted file mode 100644 index 201188d8..00000000 --- a/spec/uploadcare/entity/file_list_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe FileList do - subject { FileList } - - it 'responds to expected methods' do - expect(subject).to respond_to(:file_list, :batch_store, :batch_delete) - end - - it 'represents a file as entity' do - VCR.use_cassette('rest_file_list') do - file_list = subject.file_list - expect(file_list).to respond_to(:next, :previous, :results, :total, :files) - expect(file_list.meta).to respond_to(:next, :previous, :total, :per_page) - end - end - - it 'accepts arguments' do - VCR.use_cassette('rest_file_list_params') do - fl_with_params = FileList.file_list(limit: 2, ordering: '-datetime_uploaded') - expect(fl_with_params.meta.per_page).to eq 2 - end - end - - context 'batch_store' do - it 'returns a list of stored files' do - VCR.use_cassette('rest_file_batch_store') do - uuids = %w[e9a9f291-cc52-4388-bf65-9feec1c75ff9 c724feac-86f7-447c-b2d6-b0ced220173d] - response = subject.batch_store(uuids) - expect(response.files.length).to eq 2 - expect(response.files[0]).to be_a_kind_of(Uploadcare::Entity::File) - end - end - - it 'returns empty list if those files don`t exist' do - VCR.use_cassette('rest_file_batch_store_fail') do - uuids = %w[nonexistent another_nonexistent] - response = subject.batch_store(uuids) - expect(response.files).to be_empty - end - end - end - - context 'batch_delete' do - it 'returns a list of deleted files' do - VCR.use_cassette('rest_file_batch_delete') do - uuids = %w[935ff093-a5cf-48c5-81cf-208511bac6e6 63be5a6e-9b6b-454b-8aec-9136d5f83d0c] - response = subject.batch_delete(uuids) - expect(response.files.length).to eq 2 - expect(response.files[0]).to be_a_kind_of(Uploadcare::Entity::File) - end - end - - it 'returns empty list if those files don`t exist' do - VCR.use_cassette('rest_file_batch_delete_fail') do - uuids = %w[nonexistent another_nonexistent] - response = subject.batch_delete(uuids) - expect(response.files).to be_empty - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/file_metadata_spec.rb b/spec/uploadcare/entity/file_metadata_spec.rb deleted file mode 100644 index 4a1931fc..00000000 --- a/spec/uploadcare/entity/file_metadata_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe FileMetadata do - subject { FileMetadata } - - let(:uuid) { '2e17f5d1-d423-4de6-8ee5-6773cc4a7fa6' } - let(:key) { 'subsystem' } - - it 'responds to expected methods' do - expect(subject).to respond_to(:index, :show, :update, :delete) - end - - it 'represents a file_metadata as string' do - VCR.use_cassette('file_metadata_index') do - response = subject.index(uuid) - expect(response[:subsystem]).to eq('test') - end - end - - it 'raises error for nonexistent file' do - VCR.use_cassette('file_metadata_index_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.index(uuid) }.to raise_error(RequestError) - end - end - - it 'shows file_metadata' do - VCR.use_cassette('file_metadata_show') do - response = subject.show(uuid, key) - expect(response).to eq('test') - end - end - - it 'raises error when trying to show nonexistent key' do - VCR.use_cassette('file_metadata_show_nonexistent_key') do - key = 'nonexistent' - expect { subject.show(uuid, key) }.to raise_error(RequestError) - end - end - - it 'updates file_metadata' do - VCR.use_cassette('file_metadata_update') do - new_value = 'new test value' - response = subject.update(uuid, key, new_value) - expect(response).to eq(new_value) - end - end - - it 'creates file_metadata if it does not exist' do - VCR.use_cassette('file_metadata_create') do - key = 'new_key' - value = 'some value' - response = subject.update(uuid, key, value) - expect(response).to eq(value) - end - end - - it 'deletes file_metadata' do - VCR.use_cassette('file_metadata_delete') do - response = subject.delete(uuid, key) - expect(response).to eq('200 OK') - end - end - end - end -end diff --git a/spec/uploadcare/entity/file_spec.rb b/spec/uploadcare/entity/file_spec.rb deleted file mode 100644 index dccc75e4..00000000 --- a/spec/uploadcare/entity/file_spec.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe File do - subject { File } - it 'responds to expected methods' do - expect(subject).to respond_to(:info, :delete, :store, :local_copy, :remote_copy) - end - - it 'represents a file as entity' do - VCR.use_cassette('file_info') do - uuid = '8f64f313-e6b1-4731-96c0-6751f1e7a50a' - file = subject.info(uuid) - expect(file).to be_a_kind_of(subject) - expect(file).to respond_to(*File::RESPONSE_PARAMS) - expect(file.uuid).to eq(uuid) - end - end - - it 'raises error for nonexistent file' do - VCR.use_cassette('rest_file_info_fail') do - uuid = 'nonexistent' - expect { subject.info(uuid) }.to raise_error(RequestError) - end - end - - it 'raises error when trying to delete nonexistent file' do - VCR.use_cassette('rest_file_delete_nonexistent') do - uuid = 'nonexistent' - expect { subject.delete(uuid) }.to raise_error(RequestError) - end - end - - describe 'internal_copy' do - it 'copies file to same project' do - VCR.use_cassette('rest_file_internal_copy') do - file = subject.file('5632fc94-9dff-499f-a373-f69ea6f67ff8') - file.local_copy - end - end - end - - describe 'external_copy' do - it 'copies file to remote storage' do - VCR.use_cassette('rest_file_remote_copy') do - target = 'uploadcare-test' - uuid = '1b959c59-9605-4879-946f-08fdb5ea3e9d' - file = subject.file(uuid) - expect(file.remote_copy(target)).to match(%r{#{target}/#{uuid}/}) - end - end - - it 'raises an error when project does not have given storage' do - VCR.use_cassette('rest_file_external_copy') do - file = subject.file('5632fc94-9dff-499f-a373-f69ea6f67ff8') - # I don't have custom storage, but this error recognises what this method tries to do - msg = 'Project has no storage with provided name.' - expect { file.remote_copy('16d8625b4c5c4a372a8f') }.to raise_error(RequestError, msg) - end - end - end - - describe 'uuid' do - it 'returns uuid, even if only url is defined' do - file = File.new(url: 'https://ucarecdn.com/35b7fcd7-9bca-40e1-99b1-2adcc21c405d/123.jpg') - expect(file.uuid).to eq '35b7fcd7-9bca-40e1-99b1-2adcc21c405d' - end - end - - describe 'datetime_stored' do - it 'returns datetime_stored, with deprecated warning' do - VCR.use_cassette('file_info') do - url = 'https://ucarecdn.com/8f64f313-e6b1-4731-96c0-6751f1e7a50a' - file = File.new(url: url) - logger = Uploadcare.config.logger - file.load - allow(logger).to receive(:warn).with('datetime_stored property has been deprecated, and will be removed without a replacement in future.') - datetime_stored = file.datetime_stored - expect(logger).to have_received(:warn).with('datetime_stored property has been deprecated, and will be removed without a replacement in future.') - expect(datetime_stored).not_to be_nil - end - end - end - - describe 'load' do - it 'performs load request' do - VCR.use_cassette('file_info') do - url = 'https://ucarecdn.com/8f64f313-e6b1-4731-96c0-6751f1e7a50a' - file = File.new(url: url) - file.load - expect(file.datetime_uploaded).not_to be_nil - end - end - end - - describe 'file conversion' do - let(:url) { "https://ucarecdn.com/#{source_file_uuid}" } - let(:file) { File.new(url: url) } - - shared_examples 'new file conversion' do - it 'performs a convert request', :aggregate_failures do - VCR.use_cassette(convert_cassette) do - VCR.use_cassette(get_file_cassette) do - expect(new_file.uuid).not_to be_empty - expect(new_file.uuid).not_to eq source_file_uuid - end - end - end - end - - context 'when converting a document' do - let(:source_file_uuid) { '8f64f313-e6b1-4731-96c0-6751f1e7a50a' } - let(:new_file) { file.convert_document({ format: 'png', page: 1 }) } - - it_behaves_like 'new file conversion' do - let(:convert_cassette) { 'document_convert_convert_many' } - let(:get_file_cassette) { 'document_convert_file_info' } - end - end - - context 'when converting a video' do - let(:source_file_uuid) { 'e30112d7-3a90-4931-b2c5-688cbb46d3ac' } - let(:new_file) do - file.convert_video( - { - format: 'ogg', - quality: 'best', - cut: { start_time: '0:0:0.0', length: 'end' }, - size: { resize_mode: 'change_ratio', width: '600', height: '400' }, - thumb: { N: 1, number: 2 } - } - ) - end - - it_behaves_like 'new file conversion' do - let(:convert_cassette) { 'video_convert_convert_many' } - let(:get_file_cassette) { 'video_convert_file_info' } - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/group_list_spec.rb b/spec/uploadcare/entity/group_list_spec.rb deleted file mode 100644 index 6bda6505..00000000 --- a/spec/uploadcare/entity/group_list_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe GroupList do - subject { GroupList } - it 'responds to expected methods' do - %i[list].each do |method| - expect(subject).to respond_to(method) - end - end - - context 'list' do - before do - VCR.use_cassette('rest_list_groups_limited') do - @groups = subject.list(limit: 2) - end - end - - it 'represents a file group' do - expect(@groups.groups[0]).to be_a_kind_of(Group) - end - - it 'responds to pagination methods' do - %i[previous_page next_page load].each do |method| - expect(@groups).to respond_to(method) - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/group_spec.rb b/spec/uploadcare/entity/group_spec.rb deleted file mode 100644 index 805411d8..00000000 --- a/spec/uploadcare/entity/group_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe Group do - subject { Group } - it 'responds to expected methods' do - %i[create info store delete].each do |method| - expect(subject).to respond_to(method) - end - end - - context 'info' do - before do - VCR.use_cassette('upload_group_info') do - @group = subject.info('bbc75785-9016-4656-9c6e-64a76b45b0b8~2') - end - end - - it 'represents a file group' do - file_fields = %i[id datetime_created datetime_stored files_count cdn_url url files] - file_fields.each do |method| - expect(@group).to respond_to(method) - end - end - - it 'has files' do - expect(@group.files).not_to be_empty - expect(@group.files.first).to be_a_kind_of(Uploadcare::Entity::File) - end - end - - describe 'id' do - it 'returns id, even if only cdn_url is defined' do - group = Group.new(cdn_url: 'https://ucarecdn.com/bbc75785-9016-4656-9c6e-64a76b45b0b8~2') - expect(group.id).to eq 'bbc75785-9016-4656-9c6e-64a76b45b0b8~2' - end - end - - describe 'load' do - it 'performs load request' do - VCR.use_cassette('upload_group_info') do - cdn_url = 'https://ucarecdn.com/bbc75785-9016-4656-9c6e-64a76b45b0b8~2' - group = Group.new(cdn_url: cdn_url) - group.load - expect(group.files_count).not_to be_nil - end - end - end - - describe 'delete' do - it 'deletes a file group' do - VCR.use_cassette('upload_group_delete') do - response = subject.delete('bbc75785-9016-4656-9c6e-64a76b45b0b8~2') - expect(response).to eq('200 OK') - end - end - - it 'raises error for nonexistent file' do - VCR.use_cassette('group_delete_nonexistent_uuid') do - uuid = 'nonexistent' - expect { subject.delete(uuid) }.to raise_error(RequestError) - end - end - end - end - end -end diff --git a/spec/uploadcare/entity/project_spec.rb b/spec/uploadcare/entity/project_spec.rb deleted file mode 100644 index 3256bcda..00000000 --- a/spec/uploadcare/entity/project_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe Project do - before do - VCR.use_cassette('project') do - @project = Project.show - end - end - - it 'represents a project as an entity' do - expect(@project).to be_kind_of Uploadcare::Entity::Project - end - - it 'responds to project api methods' do - expect(@project).to respond_to(:collaborators, :name, :pub_key, :autostore_enabled) - end - end - end -end diff --git a/spec/uploadcare/entity/uploader_spec.rb b/spec/uploadcare/entity/uploader_spec.rb deleted file mode 100644 index 683323d6..00000000 --- a/spec/uploadcare/entity/uploader_spec.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Uploadcare::Entity::Uploader do - subject { Uploadcare::Entity::Uploader } - let!(:file) { File.open('spec/fixtures/kitten.jpeg') } - let!(:another_file) { File.open('spec/fixtures/another_kitten.jpeg') } - let!(:big_file) { File.open('spec/fixtures/big.jpeg') } - - describe 'upload_many' do - it 'returns a hash of filenames and uids', :aggregate_failures do - VCR.use_cassette('upload_upload_many') do - uploads_list = subject.upload([file, another_file]) - expect(uploads_list.length).to eq 2 - first_upload = uploads_list.first - expect(first_upload.original_filename).not_to be_empty - expect(first_upload.uuid).not_to be_empty - end - end - - describe 'upload_one' do - it 'returns a file', :aggregate_failures do - VCR.use_cassette('upload_upload_one') do - upload = subject.upload(file) - expect(upload).to be_kind_of(Uploadcare::Entity::File) - expect(file.path).to end_with(upload.original_filename.to_s) - expect(file.size).to eq(upload.size) - end - end - - context 'when the secret key is missing' do - it 'returns a file without details', :aggregate_failures do - Uploadcare.config.secret_key = nil - - VCR.use_cassette('upload_upload_one_without_secret_key') do - upload = subject.upload(file) - expect(upload).to be_kind_of(Uploadcare::Entity::File) - expect(file.path).to end_with(upload.original_filename.to_s) - expect(file.size).to eq(upload.size) - end - end - end - end - - describe 'upload_from_url' do - let(:url) { 'https://placekitten.com/2250/2250' } - - before do - allow(HTTP::FormData::Multipart).to receive(:new).and_call_original - end - - it 'polls server and returns array of files' do - VCR.use_cassette('upload_upload_from_url') do - upload = subject.upload(url) - expect(upload[0]).to be_kind_of(Uploadcare::Entity::File) - expect(HTTP::FormData::Multipart).to have_received(:new).with( - a_hash_including( - 'source_url' => url - ) - ) - end - end - - context 'when signed uploads are enabled' do - before do - allow(Uploadcare.config).to receive(:sign_uploads).and_return(true) - end - - it 'includes signature' do - VCR.use_cassette('upload_upload_from_url_with_signature') do - upload = subject.upload(url) - expect(upload[0]).to be_kind_of(Uploadcare::Entity::File) - expect(HTTP::FormData::Multipart).to have_received(:new).with( - a_hash_including( - signature: instance_of(String), - expire: instance_of(Integer) - ) - ) - end - end - end - - it 'raises error with information if file upload takes time' do - Uploadcare.config.max_request_tries = 1 - VCR.use_cassette('upload_upload_from_url') do - url = 'https://placekitten.com/2250/2250' - error_str = 'Upload is taking longer than expected. Try increasing the max_request_tries config if you know your file uploads will take more time.' - expect { subject.upload(url) }.to raise_error(RetryError, error_str) - end - end - end - - describe 'multipart_upload' do - let!(:some_var) { nil } - - it 'uploads a file', :aggregate_failures do - VCR.use_cassette('upload_multipart_upload') do - # Minimal size for file to be valid for multipart upload is 10 mb - Uploadcare.config.multipart_size_threshold = 10 * 1024 * 1024 - expect(some_var).to receive(:to_s).at_least(:once).and_call_original - file = subject.multipart_upload(big_file) { some_var } - expect(file).to be_kind_of(Uploadcare::Entity::File) - expect(file.uuid).not_to be_empty - end - end - end - - describe 'get_upload_from_url_status' do - it 'gets a status of upload-from-URL' do - VCR.use_cassette('upload_get_upload_from_url_status') do - token = '0313e4e2-f2ca-4564-833b-4f71bc8cba27' - status_info = subject.get_upload_from_url_status(token).success - expect(status_info[:status]).to eq 'success' - end - end - end - end - - describe 'file_info' do - it 'returns file info without the secret key', :aggregate_failures do - uuid = 'a7f9751a-432b-4b05-936c-2f62d51d255d' - - VCR.use_cassette('upload_file_info') do - file_info = subject.file_info(uuid).success - expect(file_info[:original_filename]).not_to be_empty - expect(file_info[:size]).to be >= 0 - expect(file_info[:uuid]).to eq uuid - end - end - end -end diff --git a/spec/uploadcare/entity/webhook_spec.rb b/spec/uploadcare/entity/webhook_spec.rb deleted file mode 100644 index cc4a16ce..00000000 --- a/spec/uploadcare/entity/webhook_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - module Entity - RSpec.describe Webhook do - subject { Webhook } - it 'responds to expected methods' do - %i[list delete update].each do |method| - expect(subject).to respond_to(method) - end - end - - describe 'create' do - it 'represents a webhook' do - VCR.use_cassette('rest_webhook_create') do - target_url = 'http://ohmyz.sh' - webhook = subject.create(target_url: target_url) - %i[created event id is_active project target_url updated].each do |field| - expect(webhook[field]).not_to be_nil - end - end - end - end - - describe 'list' do - it 'returns list of webhooks' do - VCR.use_cassette('rest_webhook_list') do - webhooks = subject.list - expect(webhooks).to be_kind_of(ApiStruct::Collection) - end - end - end - end - end -end diff --git a/spec/uploadcare/error_handler_spec.rb b/spec/uploadcare/error_handler_spec.rb new file mode 100644 index 00000000..f1b70824 --- /dev/null +++ b/spec/uploadcare/error_handler_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::ErrorHandler do + let(:test_class) do + Class.new do + include Uploadcare::ErrorHandler + end + end + + let(:handler) { test_class.new } + + describe '#handle_error' do + let(:error) { double('error', response: response) } + + context 'with JSON error response' do + let(:response) do + { + status: 400, + body: '{"detail": "Invalid public key"}' + } + end + + it 'raises RequestError with detail message' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::BadRequestError, + 'Invalid public key' + ) + end + end + + context 'with JSON error response containing multiple fields' do + let(:response) do + { + status: 422, + body: '{"field1": "error1", "field2": "error2"}' + } + end + + it 'raises RequestError with combined message' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::UnprocessableEntityError, + 'field1: error1; field2: error2' + ) + end + end + + context 'with invalid JSON response' do + let(:response) do + { + status: 500, + body: 'Internal Server Error' + } + end + + it 'raises RequestError with raw body' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::InternalServerError, + 'Internal Server Error' + ) + end + end + + context 'with upload API error (status 200)' do + let(:response) do + { + status: 200, + body: '{"error": "File size exceeds limit"}' + } + end + + it 'catches upload error and raises RequestError' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::RequestError, + 'File size exceeds limit' + ) + end + end + + context 'with successful upload response (status 200, no error)' do + let(:response) do + { + status: 200, + body: '{"uuid": "12345", "size": 1024}' + } + end + + it 'raises RequestError with combined message' do + # Status 200 with success should use from_response which creates an Error for 200 + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::Error, + 'uuid: 12345; size: 1024' + ) + end + end + + context 'with empty response body' do + let(:response) do + { + status: 403, + body: '' + } + end + + it 'raises RequestError with empty message' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::ForbiddenError, + 'HTTP 403' + ) + end + end + + context 'with nil response body' do + let(:response) do + { + status: 404, + body: nil + } + end + + it 'raises RequestError with empty string' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::NotFoundError, + 'HTTP 404' + ) + end + end + + context 'with array response' do + let(:response) do + { + status: 400, + body: '["error1", "error2"]' + } + end + + it 'raises RequestError with array string representation' do + expect { handler.handle_error(error) }.to raise_error( + Uploadcare::BadRequestError + ) do |error| + expect(error.message).to eq('["error1", "error2"]') + end + end + end + end + + describe '#catch_upload_errors' do + context 'with status 200 and error field' do + it 'raises RequestError' do + response = { + status: 200, + body: '{"error": "Upload failed", "other": "data"}' + } + + expect { handler.send(:catch_upload_errors, response) }.to raise_error( + Uploadcare::RequestError, + 'Upload failed' + ) + end + end + + context 'with status 200 and no error field' do + it 'does not raise error' do + response = { + status: 200, + body: '{"success": true}' + } + + expect { handler.send(:catch_upload_errors, response) }.not_to raise_error + end + end + + context 'with non-200 status' do + it 'does not raise error' do + response = { + status: 400, + body: '{"error": "Bad request"}' + } + + expect { handler.send(:catch_upload_errors, response) }.not_to raise_error + end + end + + context 'with non-JSON response' do + it 'does not raise error' do + response = { + status: 200, + body: 'not json' + } + + # Should not raise error from catch_upload_errors itself + expect { handler.send(:catch_upload_errors, response) }.not_to raise_error + end + end + end +end diff --git a/spec/uploadcare/errors_spec.rb b/spec/uploadcare/errors_spec.rb new file mode 100644 index 00000000..f3bb98e4 --- /dev/null +++ b/spec/uploadcare/errors_spec.rb @@ -0,0 +1,387 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Uploadcare Errors' do + let(:response) do + { + status: 404, + headers: { 'content-type' => 'application/json' }, + body: { 'error' => 'Not found' } + } + end + + let(:request) do + { + method: :get, + url: 'https://api.uploadcare.com/files/123/', + headers: { 'Authorization' => 'Bearer token' } + } + end + + describe Uploadcare::Error do + subject(:error) { described_class.new('Test error', response, request) } + + it 'inherits from StandardError' do + expect(error).to be_a(StandardError) + end + + it 'stores message' do + expect(error.message).to eq('Test error') + end + + it 'stores response' do + expect(error.response).to eq(response) + end + + it 'stores request' do + expect(error.request).to eq(request) + end + + describe '#status' do + it 'returns status from response' do + expect(error.status).to eq(404) + end + + context 'without response' do + subject(:error) { described_class.new('Test error') } + + it 'returns nil' do + expect(error.status).to be_nil + end + end + end + + describe '#headers' do + it 'returns headers from response' do + expect(error.headers).to eq({ 'content-type' => 'application/json' }) + end + + context 'without response' do + subject(:error) { described_class.new('Test error') } + + it 'returns nil' do + expect(error.headers).to be_nil + end + end + end + + describe '#body' do + it 'returns body from response' do + expect(error.body).to eq({ 'error' => 'Not found' }) + end + + context 'without response' do + subject(:error) { described_class.new('Test error') } + + it 'returns nil' do + expect(error.body).to be_nil + end + end + end + end + + describe 'Error hierarchy' do + it 'has correct inheritance structure' do + # Client errors + expect(Uploadcare::ClientError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::BadRequestError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::AuthenticationError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::ForbiddenError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::NotFoundError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::MethodNotAllowedError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::NotAcceptableError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::RequestTimeoutError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::ConflictError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::GoneError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::UnprocessableEntityError.superclass).to eq(Uploadcare::ClientError) + expect(Uploadcare::RateLimitError.superclass).to eq(Uploadcare::ClientError) + + # Server errors + expect(Uploadcare::ServerError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::InternalServerError.superclass).to eq(Uploadcare::ServerError) + expect(Uploadcare::NotImplementedError.superclass).to eq(Uploadcare::ServerError) + expect(Uploadcare::BadGatewayError.superclass).to eq(Uploadcare::ServerError) + expect(Uploadcare::ServiceUnavailableError.superclass).to eq(Uploadcare::ServerError) + expect(Uploadcare::GatewayTimeoutError.superclass).to eq(Uploadcare::ServerError) + + # Network errors + expect(Uploadcare::NetworkError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::ConnectionFailedError.superclass).to eq(Uploadcare::NetworkError) + expect(Uploadcare::TimeoutError.superclass).to eq(Uploadcare::NetworkError) + expect(Uploadcare::SSLError.superclass).to eq(Uploadcare::NetworkError) + + # Configuration errors + expect(Uploadcare::ConfigurationError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::InvalidConfigurationError.superclass).to eq(Uploadcare::ConfigurationError) + expect(Uploadcare::MissingConfigurationError.superclass).to eq(Uploadcare::ConfigurationError) + + # Other errors + expect(Uploadcare::RequestError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::ConversionError.superclass).to eq(Uploadcare::Error) + expect(Uploadcare::RetryError.superclass).to eq(Uploadcare::Error) + + # Compatibility aliases + expect(Uploadcare::ThrottleError.superclass).to eq(Uploadcare::RateLimitError) + expect(Uploadcare::AuthError.superclass).to eq(Uploadcare::AuthenticationError) + end + end + + describe Uploadcare::RateLimitError do + let(:response) do + { + status: 429, + headers: { 'retry-after' => '30' }, + body: { 'error' => 'Rate limit exceeded' } + } + end + + subject(:error) { described_class.new('Rate limited', response) } + + describe '#retry_after' do + it 'returns retry-after header as integer' do + expect(error.retry_after).to eq(30) + end + + context 'without retry-after header' do + let(:response) do + { + status: 429, + headers: {}, + body: { 'error' => 'Rate limit exceeded' } + } + end + + it 'returns nil' do + expect(error.retry_after).to be_nil + end + end + + context 'without headers' do + let(:response) do + { + status: 429, + body: { 'error' => 'Rate limit exceeded' } + } + end + + it 'returns nil' do + expect(error.retry_after).to be_nil + end + end + end + end + + describe Uploadcare::RequestError do + describe '.from_response' do + context 'with 400 status' do + let(:response) { { status: 400, body: { 'error' => 'Bad request' } } } + + it 'returns BadRequestError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::BadRequestError) + expect(error.message).to eq('Bad request') + end + end + + context 'with 401 status' do + let(:response) { { status: 401, body: { 'detail' => 'Unauthorized' } } } + + it 'returns AuthenticationError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::AuthenticationError) + expect(error.message).to eq('Unauthorized') + end + end + + context 'with 403 status' do + let(:response) { { status: 403, body: { 'message' => 'Forbidden' } } } + + it 'returns ForbiddenError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::ForbiddenError) + expect(error.message).to eq('Forbidden') + end + end + + context 'with 404 status' do + let(:response) { { status: 404, body: 'Not found' } } + + it 'returns NotFoundError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::NotFoundError) + expect(error.message).to eq('Not found') + end + end + + context 'with 429 status' do + let(:response) { { status: 429, body: { 'error' => 'Too many requests' } } } + + it 'returns RateLimitError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::RateLimitError) + expect(error.message).to eq('Too many requests') + end + end + + context 'with 500 status' do + let(:response) { { status: 500, body: { 'error' => 'Server error' } } } + + it 'returns InternalServerError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::InternalServerError) + expect(error.message).to eq('Server error') + end + end + + context 'with unmapped 4xx status' do + let(:response) { { status: 418, body: { 'error' => "I'm a teapot" } } } + + it 'returns generic ClientError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::ClientError) + expect(error.message).to eq("I'm a teapot") + end + end + + context 'with unmapped 5xx status' do + let(:response) { { status: 599, body: { 'error' => 'Unknown server error' } } } + + it 'returns generic ServerError' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::ServerError) + expect(error.message).to eq('Unknown server error') + end + end + + context 'with non-error status' do + let(:response) { { status: 200, body: { 'success' => true } } } + + it 'returns generic Error' do + error = described_class.from_response(response) + expect(error).to be_a(Uploadcare::Error) + expect(error.message).to eq('HTTP 200') + end + end + + context 'with request parameter' do + let(:response) { { status: 404, body: { 'error' => 'Not found' } } } + + it 'passes request to error' do + error = described_class.from_response(response, request) + expect(error.request).to eq(request) + end + end + + describe 'message extraction' do + context 'with error field in body' do + let(:response) { { status: 400, body: { 'error' => 'Error message' } } } + + it 'uses error field' do + error = described_class.from_response(response) + expect(error.message).to eq('Error message') + end + end + + context 'with detail field in body' do + let(:response) { { status: 400, body: { 'detail' => 'Detail message' } } } + + it 'uses detail field' do + error = described_class.from_response(response) + expect(error.message).to eq('Detail message') + end + end + + context 'with message field in body' do + let(:response) { { status: 400, body: { 'message' => 'Message field' } } } + + it 'uses message field' do + error = described_class.from_response(response) + expect(error.message).to eq('Message field') + end + end + + context 'with multiple fields' do + let(:response) do + { + status: 400, + body: { + 'error' => 'Error field', + 'detail' => 'Detail field', + 'message' => 'Message field' + } + } + end + + it 'prefers error field' do + error = described_class.from_response(response) + expect(error.message).to eq('Error field') + end + end + + context 'with string body' do + let(:response) { { status: 400, body: 'String error message' } } + + it 'uses string as message' do + error = described_class.from_response(response) + expect(error.message).to eq('String error message') + end + end + + context 'with empty string body' do + let(:response) { { status: 400, body: '' } } + + it 'returns HTTP status message' do + error = described_class.from_response(response) + expect(error.message).to eq('HTTP 400') + end + end + + context 'with nil body' do + let(:response) { { status: 400, body: nil } } + + it 'returns HTTP status message' do + error = described_class.from_response(response) + expect(error.message).to eq('HTTP 400') + end + end + + context 'with non-string/hash body' do + let(:response) { { status: 400, body: %w[array body] } } + + it 'returns HTTP status message' do + error = described_class.from_response(response) + expect(error.message).to eq('HTTP 400') + end + end + end + end + + describe 'STATUS_ERROR_MAP' do + it 'has all expected mappings' do + expect(described_class::STATUS_ERROR_MAP).to eq({ + 400 => Uploadcare::BadRequestError, + 401 => Uploadcare::AuthenticationError, + 403 => Uploadcare::ForbiddenError, + 404 => Uploadcare::NotFoundError, + 405 => Uploadcare::MethodNotAllowedError, + 406 => Uploadcare::NotAcceptableError, + 408 => Uploadcare::RequestTimeoutError, + 409 => Uploadcare::ConflictError, + 410 => Uploadcare::GoneError, + 422 => Uploadcare::UnprocessableEntityError, + 429 => Uploadcare::RateLimitError, + 500 => Uploadcare::InternalServerError, + 501 => Uploadcare::NotImplementedError, + 502 => Uploadcare::BadGatewayError, + 503 => Uploadcare::ServiceUnavailableError, + 504 => Uploadcare::GatewayTimeoutError + }) + end + + it 'is frozen' do + expect(described_class::STATUS_ERROR_MAP).to be_frozen + end + end + end +end diff --git a/spec/uploadcare/exception/auth_error_spec.rb b/spec/uploadcare/exception/auth_error_spec.rb new file mode 100644 index 00000000..b3b5687b --- /dev/null +++ b/spec/uploadcare/exception/auth_error_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::AuthError do + describe '#initialize' do + it 'inherits from StandardError' do + expect(described_class.superclass).to eq(StandardError) + end + + it 'can be instantiated with a message' do + error = described_class.new('Invalid authentication') + expect(error.message).to eq('Invalid authentication') + end + + it 'can be instantiated without a message' do + error = described_class.new + expect(error.message).to eq('Uploadcare::AuthError') + end + end + + describe 'raising the error' do + it 'can be raised as an exception' do + expect { raise described_class }.to raise_error(described_class) + end + + it 'can be raised with a custom message' do + expect { raise described_class, 'API key missing' } + .to raise_error(described_class, 'API key missing') + end + end + + describe 'rescue behavior' do + it 'can be rescued as AuthError' do + result = begin + raise described_class, 'Auth failed' + rescue described_class => e + e.message + end + expect(result).to eq('Auth failed') + end + + it 'can be rescued as StandardError' do + result = begin + raise described_class, 'Auth failed' + rescue StandardError => e + e.message + end + expect(result).to eq('Auth failed') + end + end +end diff --git a/spec/uploadcare/exception/conversion_error_spec.rb b/spec/uploadcare/exception/conversion_error_spec.rb new file mode 100644 index 00000000..092d2245 --- /dev/null +++ b/spec/uploadcare/exception/conversion_error_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::ConversionError do + describe '#initialize' do + it 'inherits from StandardError' do + expect(described_class.superclass).to eq(StandardError) + end + + it 'can be instantiated with a message' do + error = described_class.new('Conversion failed') + expect(error.message).to eq('Conversion failed') + end + + it 'can be instantiated without a message' do + error = described_class.new + expect(error.message).to eq('Uploadcare::ConversionError') + end + end + + describe 'raising the error' do + it 'can be raised as an exception' do + expect { raise described_class }.to raise_error(described_class) + end + + it 'can be raised with a custom message' do + expect { raise described_class, 'Invalid conversion format' } + .to raise_error(described_class, 'Invalid conversion format') + end + end + + describe 'rescue behavior' do + it 'can be rescued as ConversionError' do + result = begin + raise described_class, 'Conversion error occurred' + rescue described_class => e + e.message + end + expect(result).to eq('Conversion error occurred') + end + + it 'can be rescued as StandardError' do + result = begin + raise described_class, 'Conversion error occurred' + rescue StandardError => e + e.message + end + expect(result).to eq('Conversion error occurred') + end + end + + describe 'use cases' do + context 'when API conversion response is invalid' do + it 'provides meaningful error messages' do + error = described_class.new('Unsupported file format for conversion') + expect(error.message).to eq('Unsupported file format for conversion') + end + end + + context 'when conversion parameters are invalid' do + it 'can indicate parameter issues' do + error = described_class.new('Invalid conversion parameters: width must be positive') + expect(error.message).to eq('Invalid conversion parameters: width must be positive') + end + end + end +end diff --git a/spec/uploadcare/exception/request_error_spec.rb b/spec/uploadcare/exception/request_error_spec.rb new file mode 100644 index 00000000..87f54f68 --- /dev/null +++ b/spec/uploadcare/exception/request_error_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::RequestError do + describe '#initialize' do + it 'inherits from StandardError' do + expect(described_class.superclass).to eq(StandardError) + end + + it 'can be instantiated with a message' do + error = described_class.new('Bad request') + expect(error.message).to eq('Bad request') + end + + it 'can be instantiated without a message' do + error = described_class.new + expect(error.message).to eq('Uploadcare::RequestError') + end + end + + describe 'raising the error' do + it 'can be raised as an exception' do + expect { raise described_class }.to raise_error(described_class) + end + + it 'can be raised with a custom message' do + expect { raise described_class, '404 Not Found' } + .to raise_error(described_class, '404 Not Found') + end + end + + describe 'rescue behavior' do + it 'can be rescued as RequestError' do + result = begin + raise described_class, 'Request failed' + rescue described_class => e + e.message + end + expect(result).to eq('Request failed') + end + + it 'can be rescued as StandardError' do + result = begin + raise described_class, 'Request failed' + rescue StandardError => e + e.message + end + expect(result).to eq('Request failed') + end + end + + describe 'use cases' do + context 'when API returns an error' do + it 'can represent various HTTP errors' do + errors = { + '400' => 'Bad Request', + '401' => 'Unauthorized', + '403' => 'Forbidden', + '404' => 'Not Found', + '500' => 'Internal Server Error' + } + + errors.each do |code, message| + error = described_class.new("#{code}: #{message}") + expect(error.message).to eq("#{code}: #{message}") + end + end + end + + context 'when request validation fails' do + it 'can indicate validation errors' do + error = described_class.new('Invalid request parameters') + expect(error.message).to eq('Invalid request parameters') + end + end + + context 'when API response is malformed' do + it 'can indicate parsing errors' do + error = described_class.new('Invalid JSON response from API') + expect(error.message).to eq('Invalid JSON response from API') + end + end + end +end diff --git a/spec/uploadcare/exception/retry_error_spec.rb b/spec/uploadcare/exception/retry_error_spec.rb new file mode 100644 index 00000000..a2ad4e58 --- /dev/null +++ b/spec/uploadcare/exception/retry_error_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::RetryError do + describe '#initialize' do + it 'inherits from StandardError' do + expect(described_class.superclass).to eq(StandardError) + end + + it 'can be instantiated with a message' do + error = described_class.new('Request needs retry') + expect(error.message).to eq('Request needs retry') + end + + it 'can be instantiated without a message' do + error = described_class.new + expect(error.message).to eq('Uploadcare::RetryError') + end + end + + describe 'raising the error' do + it 'can be raised as an exception' do + expect { raise described_class }.to raise_error(described_class) + end + + it 'can be raised with a custom message' do + expect { raise described_class, 'Network timeout, retry needed' } + .to raise_error(described_class, 'Network timeout, retry needed') + end + end + + describe 'rescue behavior' do + it 'can be rescued as RetryError' do + result = begin + raise described_class, 'Retry required' + rescue described_class => e + e.message + end + expect(result).to eq('Retry required') + end + + it 'can be rescued as StandardError' do + result = begin + raise described_class, 'Retry required' + rescue StandardError => e + e.message + end + expect(result).to eq('Retry required') + end + end + + describe 'use cases' do + context 'when network issues occur' do + it 'can indicate connection problems' do + error = described_class.new('Connection reset by peer') + expect(error.message).to eq('Connection reset by peer') + end + + it 'can indicate timeout issues' do + error = described_class.new('Request timeout after 30 seconds') + expect(error.message).to eq('Request timeout after 30 seconds') + end + end + + context 'when server returns retryable errors' do + it 'can indicate 503 Service Unavailable' do + error = described_class.new('503: Service temporarily unavailable') + expect(error.message).to eq('503: Service temporarily unavailable') + end + + it 'can indicate 502 Bad Gateway' do + error = described_class.new('502: Bad Gateway') + expect(error.message).to eq('502: Bad Gateway') + end + end + + context 'in retry middleware' do + it 'can be used to trigger retry logic' do + retries = 0 + max_retries = 3 + + begin + retries += 1 + raise described_class, 'Temporary failure' if retries < max_retries + + 'Success' + rescue described_class + retry if retries < max_retries + end + + expect(retries).to eq(max_retries) + end + end + end +end diff --git a/spec/uploadcare/exception/throttle_error_spec.rb b/spec/uploadcare/exception/throttle_error_spec.rb new file mode 100644 index 00000000..34c8611e --- /dev/null +++ b/spec/uploadcare/exception/throttle_error_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::ThrottleError do + describe '#initialize' do + it 'inherits from StandardError' do + expect(described_class.superclass).to eq(StandardError) + end + + it 'can be instantiated with default timeout' do + error = described_class.new + expect(error.timeout).to eq(10.0) + expect(error.message).to eq('Uploadcare::ThrottleError') + end + + it 'can be instantiated with custom timeout' do + error = described_class.new(30.0) + expect(error.timeout).to eq(30.0) + end + + it 'stores timeout as an accessible attribute' do + error = described_class.new(15.5) + expect(error.timeout).to eq(15.5) + end + end + + describe '#timeout' do + it 'returns the timeout value' do + error = described_class.new(25.0) + expect(error.timeout).to eq(25.0) + end + + it 'is read-only' do + error = described_class.new(20.0) + expect { error.timeout = 30.0 }.to raise_error(NoMethodError) + end + end + + describe 'raising the error' do + it 'can be raised as an exception' do + expect { raise described_class }.to raise_error(described_class) + end + + it 'can be raised with a timeout' do + expect { raise described_class, 60.0 } + .to raise_error(described_class) do |error| + expect(error.timeout).to eq(60.0) + end + end + end + + describe 'rescue behavior' do + it 'can be rescued as ThrottleError' do + result = begin + raise described_class, 45.0 + rescue described_class => e + e.timeout + end + expect(result).to eq(45.0) + end + + it 'can be rescued as StandardError' do + result = begin + raise described_class, 15.0 + rescue StandardError => e + e.is_a?(described_class) + end + expect(result).to be true + end + end + + describe 'use cases' do + context 'when API rate limit is exceeded' do + it 'provides timeout information for retry logic' do + error = described_class.new(30.0) + expect(error.timeout).to eq(30.0) + end + + it 'can use different timeouts for different scenarios' do + short_throttle = described_class.new(5.0) + long_throttle = described_class.new(120.0) + + expect(short_throttle.timeout).to eq(5.0) + expect(long_throttle.timeout).to eq(120.0) + end + end + + context 'in throttle handling logic' do + it 'can be used to implement backoff' do + raise described_class, 2.0 + rescue described_class => e + sleep_time = e.timeout + expect(sleep_time).to eq(2.0) + end + + it 'preserves timeout through exception chain' do + original_timeout = 25.5 + + begin + begin + raise described_class, original_timeout + rescue described_class => e + raise e # re-raise + end + rescue described_class => e + expect(e.timeout).to eq(original_timeout) + end + end + end + + context 'with retry-after headers' do + it 'can represent server-specified retry delays' do + # Simulating a 429 response with Retry-After header + retry_after_seconds = 45.0 + error = described_class.new(retry_after_seconds) + + expect(error.timeout).to eq(retry_after_seconds) + end + end + end + + describe 'edge cases' do + it 'handles zero timeout' do + error = described_class.new(0.0) + expect(error.timeout).to eq(0.0) + end + + it 'handles fractional timeouts' do + error = described_class.new(0.5) + expect(error.timeout).to eq(0.5) + end + + it 'handles very large timeouts' do + error = described_class.new(3600.0) + expect(error.timeout).to eq(3600.0) + end + end +end diff --git a/spec/uploadcare/features/error_spec.rb b/spec/uploadcare/features/error_spec.rb deleted file mode 100644 index 92a396e4..00000000 --- a/spec/uploadcare/features/error_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - RSpec.describe 'User-friendly errors' do - # Ideally, this gem should raise errors as they are described in API - let!(:file) { ::File.open('spec/fixtures/kitten.jpeg') } - - context 'REST API' do - it 'raises a readable error on failed requests' do - VCR.use_cassette('rest_file_info_fail') do - uuid = 'nonexistent' - expect { Entity::File.info(uuid) }.to raise_error(RequestError, 'Not found.') - end - end - end - - context 'Upload API' do - # For some reason, upload errors come with status 200; - # You need to actually read the response to find out that it is in fact an error - it 'raises readable errors with incorrect 200 responses' do - VCR.use_cassette('upload_error') do - Uploadcare.config.public_key = 'baz' - begin - Entity::Uploader.upload(file) - rescue StandardError => e - expect(e.to_s).to include('UPLOADCARE_PUB_KEY is invalid') - end - end - end - end - end -end diff --git a/spec/uploadcare/features/throttling_spec.rb b/spec/uploadcare/features/throttling_spec.rb deleted file mode 100644 index 6b4a3528..00000000 --- a/spec/uploadcare/features/throttling_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Uploadcare - RSpec.describe 'throttling' do - let!(:file) { ::File.open('spec/fixtures/kitten.jpeg') } - Kernel.class_eval do - # prevent waiting time - def sleep(_time); end - end - - context 'REST API' do - context 'cassette with 3 throttled responses and one proper response' do - it 'makes multiple attempts on throttled requests' do - VCR.use_cassette('throttling') do - expect { Entity::File.info('8f64f313-e6b1-4731-96c0-6751f1e7a50a') }.not_to raise_error - # make sure this cassette actually had 3 throttled responses - assert_requested(:get, 'https://api.uploadcare.com/files/8f64f313-e6b1-4731-96c0-6751f1e7a50a/', times: 4) - end - end - end - end - - context 'Upload API' do - context 'cassette with a throttled response' do - it 'makes multiple attempts on throttled requests' do - VCR.use_cassette('upload_throttling') do - expect { Entity::Uploader.upload(file) }.not_to raise_error - # make sure this cassette actually had a throttled response - assert_requested(:post, 'https://upload.uploadcare.com/base/', times: 2) - end - end - end - end - end -end diff --git a/spec/uploadcare/middleware/base_spec.rb b/spec/uploadcare/middleware/base_spec.rb new file mode 100644 index 00000000..3bd76996 --- /dev/null +++ b/spec/uploadcare/middleware/base_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Middleware::Base do + let(:app) { double('app') } + let(:middleware) { described_class.new(app) } + let(:env) { { method: :get, url: 'https://api.uploadcare.com/test' } } + + describe '#initialize' do + it 'stores the app' do + expect(middleware.instance_variable_get(:@app)).to eq(app) + end + end + + describe '#call' do + it 'passes environment to the app' do + expect(app).to receive(:call).with(env) + middleware.call(env) + end + + it 'returns app response' do + response = { status: 200, body: 'OK' } + allow(app).to receive(:call).and_return(response) + + expect(middleware.call(env)).to eq(response) + end + + it 'does not modify the environment' do + original_env = env.dup + allow(app).to receive(:call) + + middleware.call(env) + expect(env).to eq(original_env) + end + end + + describe 'inheritance' do + let(:custom_middleware_class) do + Class.new(described_class) do + def call(env) + env[:custom] = true + super + end + end + end + + let(:custom_middleware) { custom_middleware_class.new(app) } + + it 'allows subclasses to extend behavior' do + expect(app).to receive(:call) do |env| + expect(env[:custom]).to be true + end + + custom_middleware.call(env) + end + end + + describe 'middleware chaining' do + let(:app) { ->(env) { { status: 200, body: env[:data] } } } + + let(:first_middleware_class) do + Class.new(described_class) do + def call(env) + env[:data] ||= [] + env[:data] << 'first' + super + end + end + end + + let(:second_middleware_class) do + Class.new(described_class) do + def call(env) + env[:data] ||= [] + env[:data] << 'second' + super + end + end + end + + it 'allows multiple middleware to be chained' do + stack = first_middleware_class.new( + second_middleware_class.new(app) + ) + + result = stack.call({}) + expect(result[:body]).to eq(%w[first second]) + end + end + + describe 'error handling' do + context 'when app raises an error' do + before do + allow(app).to receive(:call).and_raise(StandardError, 'App error') + end + + it 'does not catch the error' do + expect { middleware.call(env) }.to raise_error(StandardError, 'App error') + end + end + end + + describe 'thread safety' do + it 'can be used concurrently' do + call_count = 0 + mutex = Mutex.new + + allow(app).to receive(:call) do |_env| + sleep(0.01) # Simulate some work + mutex.synchronize { call_count += 1 } + { status: 200 } + end + + threads = 5.times.map do |i| + Thread.new do + middleware.call({ id: i }) + end + end + + threads.each(&:join) + expect(call_count).to eq(5) + end + end +end diff --git a/spec/uploadcare/middleware/logger_spec.rb b/spec/uploadcare/middleware/logger_spec.rb new file mode 100644 index 00000000..6b5ebd76 --- /dev/null +++ b/spec/uploadcare/middleware/logger_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'logger' + +RSpec.describe Uploadcare::Middleware::Logger do + let(:app) { double('app') } + let(:logger) { instance_double(Logger) } + let(:middleware) { described_class.new(app, logger) } + let(:env) do + { + method: :get, + url: 'https://api.uploadcare.com/test', + request_headers: { 'Authorization' => 'Bearer token' }, + body: { secret_key: 'secret' } + } + end + + describe '#call' do + context 'when request succeeds' do + let(:response) { { status: 200, headers: {}, body: { result: 'success' } } } + + before do + allow(app).to receive(:call).and_return(response) + allow(logger).to receive(:info) + allow(logger).to receive(:debug) + end + + it 'logs the request' do + expect(logger).to receive(:info).with('[Uploadcare] Request: GET https://api.uploadcare.com/test') + middleware.call(env) + end + + it 'logs the response' do + expect(logger).to receive(:info).with(/\[Uploadcare\] Response: 200 \(\d+\.\d+ms\)/) + middleware.call(env) + end + + it 'filters sensitive headers' do + expect(logger).to receive(:debug).with( + '[Uploadcare] Headers: {"authorization"=>"[FILTERED]"}' + ) + middleware.call(env) + end + + it 'filters sensitive body data' do + expect(logger).to receive(:debug).with( + '[Uploadcare] Body: {:secret_key=>"[FILTERED]"}' + ) + middleware.call(env) + end + + it 'returns the response' do + expect(middleware.call(env)).to eq(response) + end + end + + context 'when request fails' do + let(:error) { StandardError.new('Connection failed') } + + before do + allow(app).to receive(:call).and_raise(error) + allow(logger).to receive(:info) + allow(logger).to receive(:error) + end + + it 'logs the error' do + expect(logger).to receive(:error).with(/\[Uploadcare\] Error: StandardError - Connection failed/) + expect { middleware.call(env) }.to raise_error(StandardError) + end + + it 're-raises the error' do + expect { middleware.call(env) }.to raise_error(StandardError, 'Connection failed') + end + end + + context 'with default logger' do + let(:middleware) { described_class.new(app) } + + it 'uses stdout logger by default' do + allow(app).to receive(:call).and_return({ status: 200 }) + expect { middleware.call(env) }.to output(/\[Uploadcare\] Request/).to_stdout + end + end + end + + describe '#truncate' do + it 'truncates long strings' do + long_string = 'a' * 2000 + result = middleware.send(:truncate, long_string, 100) + expect(result).to eq("#{'a' * 100}... (truncated)") + end + + it 'does not truncate short strings' do + short_string = 'short' + result = middleware.send(:truncate, short_string, 100) + expect(result).to eq('short') + end + end +end diff --git a/spec/uploadcare/middleware/retry_spec.rb b/spec/uploadcare/middleware/retry_spec.rb new file mode 100644 index 00000000..15de0beb --- /dev/null +++ b/spec/uploadcare/middleware/retry_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Middleware::Retry do + let(:app) { double('app') } + let(:logger) { instance_double(Logger) } + let(:middleware) { described_class.new(app, logger: logger) } + let(:env) do + { + method: :get, + url: 'https://api.uploadcare.com/test' + } + end + + describe '#call' do + context 'when request succeeds' do + let(:response) { { status: 200, body: 'success' } } + + it 'returns response without retry' do + expect(app).to receive(:call).once.and_return(response) + expect(middleware.call(env)).to eq(response) + end + end + + context 'when request fails with retryable status' do + let(:failed_response) { { status: 503, headers: {} } } + let(:success_response) { { status: 200, body: 'success' } } + + before do + allow(middleware).to receive(:sleep) # Don't actually sleep in tests + allow(logger).to receive(:warn) + end + + it 'retries and succeeds' do + expect(app).to receive(:call).and_return(failed_response, success_response) + expect(middleware.call(env)).to eq(success_response) + end + + it 'logs retry attempts' do + allow(app).to receive(:call).and_return(failed_response, success_response) + expect(logger).to receive(:warn).with(%r{Retrying GET.*attempt 1/3.*status code 503}) + middleware.call(env) + end + + it 'respects max retries' do + allow(app).to receive(:call).and_return(failed_response) + middleware = described_class.new(app, max_retries: 2, logger: logger) + allow(middleware).to receive(:sleep) + + expect(app).to receive(:call).exactly(3).times # initial + 2 retries + middleware.call(env) + end + end + + context 'with retry-after header' do + let(:failed_response) { { status: 429, headers: { 'retry-after' => '5' } } } + let(:success_response) { { status: 200 } } + + it 'uses retry-after value for delay' do + allow(app).to receive(:call).and_return(failed_response, success_response) + allow(logger).to receive(:warn) + + expect(middleware).to receive(:sleep).with(satisfy { |val| val >= 5 }) + middleware.call(env) + end + end + + context 'with connection errors' do + let(:error) { Faraday::TimeoutError.new('timeout') } + let(:success_response) { { status: 200 } } + + before do + allow(middleware).to receive(:sleep) + allow(logger).to receive(:warn) + end + + it 'retries on timeout errors' do + expect(app).to receive(:call).and_raise(error).ordered + expect(app).to receive(:call).and_return(success_response).ordered + + expect(middleware.call(env)).to eq(success_response) + end + + it 'does not retry non-retryable errors' do + non_retryable_error = StandardError.new('other error') + expect(app).to receive(:call).once.and_raise(non_retryable_error) + + expect { middleware.call(env) }.to raise_error(StandardError, 'other error') + end + end + + context 'with non-retryable methods' do + let(:post_env) { env.merge(method: :post) } + let(:failed_response) { { status: 503 } } + + it 'does not retry POST requests by default' do + expect(app).to receive(:call).once.and_return(failed_response) + expect(middleware.call(post_env)).to eq(failed_response) + end + end + + context 'with custom retry logic' do + let(:custom_retry) { ->(_env, response) { response[:status] == 418 } } + let(:middleware) do + described_class.new(app, retry_if: custom_retry, logger: logger) + end + let(:teapot_response) { { status: 418 } } + let(:success_response) { { status: 200 } } + + it 'uses custom retry logic' do + allow(middleware).to receive(:sleep) + allow(logger).to receive(:warn) + + expect(app).to receive(:call).and_return(teapot_response, success_response) + expect(middleware.call(env)).to eq(success_response) + end + end + end + + describe '#calculate_delay' do + it 'uses exponential backoff' do + middleware = described_class.new(app, backoff_factor: 2) + + expect(middleware.send(:calculate_delay, 1)).to be_between(1, 1.3) + expect(middleware.send(:calculate_delay, 2)).to be_between(2, 2.6) + expect(middleware.send(:calculate_delay, 3)).to be_between(4, 5.2) + end + end +end diff --git a/spec/uploadcare/param/authentication_header_spec.rb b/spec/uploadcare/param/authentication_header_spec.rb deleted file mode 100644 index b011bf91..00000000 --- a/spec/uploadcare/param/authentication_header_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/authentication_header' - -module Uploadcare - RSpec.describe Param::AuthenticationHeader do - subject { Param::AuthenticationHeader } - - before do - allow(Param::SimpleAuthHeader).to receive(:call).and_return('SimpleAuth called') - allow(Param::SecureAuthHeader).to receive(:call).and_return('SecureAuth called') - end - - it 'decides which header to use depending on configuration' do - Uploadcare.config.auth_type = 'Uploadcare.Simple' - expect(subject.call).to eq('SimpleAuth called') - Uploadcare.config.auth_type = 'Uploadcare' - expect(subject.call).to eq('SecureAuth called') - end - - it 'raise argument error if public_key is blank' do - Uploadcare.config.public_key = '' - expect { subject.call }.to raise_error(AuthError, 'Public Key is blank.') - end - - it 'raise argument error if secret_key is blank' do - Uploadcare.config.secret_key = '' - expect { subject.call }.to raise_error(AuthError, 'Secret Key is blank.') - end - end -end diff --git a/spec/uploadcare/param/conversion/document/processing_job_url_builder_spec.rb b/spec/uploadcare/param/conversion/document/processing_job_url_builder_spec.rb deleted file mode 100644 index 18b61fd9..00000000 --- a/spec/uploadcare/param/conversion/document/processing_job_url_builder_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/conversion/document/processing_job_url_builder' - -module Uploadcare - module Param - module Conversion - module Document - RSpec.describe Uploadcare::Param::Conversion::Document::ProcessingJobUrlBuilder do - subject { described_class.call(**arguments) } - - let(:uuid) { 'b054825b-17f2-4746-9f0c-8feee4d81ca1' } - let(:arguments) do - { - uuid: uuid, - format: 'png' - } - end - - shared_examples 'URL building' do - it 'builds a URL' do - expect(subject).to eq expected_url - end - end - - context 'when building an URL' do - context 'and when only the :format param is present' do - let(:expected_url) do - "#{uuid}/document/-/format/#{arguments[:format]}/" - end - - it_behaves_like 'URL building' - end - - context 'and when :format and :page params are present' do - let(:arguments) { super().merge(page: 1) } - let(:expected_url) do - "#{uuid}/document/-/format/#{arguments[:format]}/-/page/#{arguments[:page]}/" - end - - it_behaves_like 'URL building' - end - end - end - end - end - end -end diff --git a/spec/uploadcare/param/conversion/video/processing_job_url_builder_spec.rb b/spec/uploadcare/param/conversion/video/processing_job_url_builder_spec.rb deleted file mode 100644 index c47b57de..00000000 --- a/spec/uploadcare/param/conversion/video/processing_job_url_builder_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/conversion/video/processing_job_url_builder' - -module Uploadcare - module Param - module Conversion - module Video - RSpec.describe Uploadcare::Param::Conversion::Video::ProcessingJobUrlBuilder do - subject { described_class.call(**arguments) } - - let(:uuid) { 'b054825b-17f2-4746-9f0c-8feee4d81ca1' } - let(:arguments) do - { - uuid: uuid, - size: { resize_mode: 'preserve_ratio', width: '600', height: '400' }, - quality: 'best', - format: 'ogg', - cut: { start_time: '1:1:1.1', length: '2:1:1.1' }, - thumbs: { N: 20, number: 4 } - } - end - - shared_examples 'URL building' do - it 'builds a URL' do - expect(subject).to eq expected_url - end - end - - context 'when building an URL' do - context 'and when all operations are present' do - let(:expected_url) do - "#{uuid}/video/-" \ - "/size/#{arguments[:size][:width]}x#{arguments[:size][:height]}/#{arguments[:size][:resize_mode]}/-" \ - "/quality/#{arguments[:quality]}/-" \ - "/format/#{arguments[:format]}/-" \ - "/cut/#{arguments[:cut][:start_time]}/#{arguments[:cut][:length]}/-" \ - "/thumbs~#{arguments[:thumbs][:N]}/#{arguments[:thumbs][:number]}/" - end - - it_behaves_like 'URL building' - end - - context 'and when only the :size operation is present' do - let(:arguments) { super().select { |k, _v| %i[uuid size].include?(k) } } - let(:expected_url) do - "#{uuid}/video/-" \ - "/size/#{arguments[:size][:width]}x#{arguments[:size][:height]}/#{arguments[:size][:resize_mode]}/" - end - - it_behaves_like 'URL building' - end - - %i[quality format].each do |param| - context "and when only the :#{param} operation is present" do - let(:arguments) { super().select { |k, _v| [:uuid, param].include?(k) } } - let(:expected_url) { "#{uuid}/video/-/#{param}/#{arguments[param]}/" } - - it_behaves_like 'URL building' - end - end - - context 'and when only the :cut operation is present' do - let(:arguments) { super().select { |k, _v| %i[uuid cut].include?(k) } } - let(:expected_url) do - "#{uuid}/video/-/cut/#{arguments[:cut][:start_time]}/#{arguments[:cut][:length]}/" - end - - it_behaves_like 'URL building' - end - - context 'and when only the :thumbs operation is present' do - let(:arguments) { super().select { |k, _v| %i[uuid thumbs].include?(k) } } - let(:expected_url) do - "#{uuid}/video/-/thumbs~#{arguments[:thumbs][:N]}/#{arguments[:thumbs][:number]}/" - end - - it_behaves_like 'URL building' - end - end - end - end - end - end -end diff --git a/spec/uploadcare/param/secure_auth_header_spec.rb b/spec/uploadcare/param/secure_auth_header_spec.rb deleted file mode 100644 index 3f2b6e90..00000000 --- a/spec/uploadcare/param/secure_auth_header_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/secure_auth_header' - -module Uploadcare - RSpec.describe Param::SecureAuthHeader do - subject { Param::SecureAuthHeader } - describe 'signature' do - before(:each) do - allow(Time).to receive(:now).and_return(Time.parse('2017.02.02 12:58:50 +0000')) - Uploadcare.config.public_key = 'pub' - Uploadcare.config.secret_key = 'priv' - end - - it 'returns correct headers for complex authentication' do - headers = subject.call(method: 'POST', uri: '/path', content_type: 'application/x-www-form-urlencoded') - expected = '47af79c7f800de03b9e0f2dbb1e589cba7b210c2' - expect(headers[:Authorization]).to eq "Uploadcare pub:#{expected}" - end - end - end -end diff --git a/spec/uploadcare/param/simple_auth_header_spec.rb b/spec/uploadcare/param/simple_auth_header_spec.rb deleted file mode 100644 index d7532d39..00000000 --- a/spec/uploadcare/param/simple_auth_header_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/simple_auth_header' - -module Uploadcare - RSpec.describe Param::SimpleAuthHeader do - subject { Uploadcare::Param::SimpleAuthHeader } - describe 'Uploadcare.Simple' do - before do - Uploadcare.config.public_key = 'foo' - Uploadcare.config.secret_key = 'bar' - Uploadcare.config.auth_type = 'Uploadcare.Simple' - end - - it 'returns correct headers for simple authentication' do - expect(subject.call).to eq(Authorization: 'Uploadcare.Simple foo:bar') - end - end - end -end diff --git a/spec/uploadcare/param/upload/signature_generator_spec.rb b/spec/uploadcare/param/upload/signature_generator_spec.rb deleted file mode 100644 index 93103ae2..00000000 --- a/spec/uploadcare/param/upload/signature_generator_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# @see https://uploadcare.com/docs/api_reference/upload/signed_uploads/ - -require 'spec_helper' -require 'param/upload/signature_generator' - -module Uploadcare - module Param - module Upload - RSpec.describe Uploadcare::Param::Upload::SignatureGenerator do - let!(:expires_at) { 1_454_903_856 } - let!(:expected_result) { { signature: '46f70d2b4fb6196daeb2c16bf44a7f1e', expire: expires_at } } - - before do - allow(Time).to receive(:now).and_return(expires_at - (60 * 30)) - Uploadcare.config.secret_key = 'project_secret_key' - end - - it 'generates body params needed for signing uploads' do - signature_body = SignatureGenerator.call - expect(signature_body).to eq expected_result - end - end - end - end -end diff --git a/spec/uploadcare/param/upload/upload_params_generator_spec.rb b/spec/uploadcare/param/upload/upload_params_generator_spec.rb deleted file mode 100644 index 862c0d0d..00000000 --- a/spec/uploadcare/param/upload/upload_params_generator_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# @see https://uploadcare.com/docs/api_reference/upload/signed_uploads/ - -require 'spec_helper' -require 'param/upload/upload_params_generator' - -module Uploadcare - module Param - module Upload - RSpec.describe Uploadcare::Param::Upload::UploadParamsGenerator do - subject { Uploadcare::Param::Upload::UploadParamsGenerator } - - it 'generates basic upload params headers' do - params = subject.call - expect(params['UPLOADCARE_PUB_KEY']).not_to be_nil - expect(params['UPLOADCARE_STORE']).not_to be_nil - end - end - end - end -end diff --git a/spec/uploadcare/param/user_agent_spec.rb b/spec/uploadcare/param/user_agent_spec.rb deleted file mode 100644 index c2936a8b..00000000 --- a/spec/uploadcare/param/user_agent_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/user_agent' - -module Uploadcare - RSpec.describe Param::UserAgent do - subject { Param::UserAgent } - - it 'contains gem version' do - user_agent_string = subject.call - expect(user_agent_string).to include(Uploadcare::VERSION) - end - - it 'contains framework data when it is specified' do - Uploadcare.config.framework_data = 'Rails' - expect(subject.call).to include('; Rails') - Uploadcare.config.framework_data = '' - expect(subject.call).not_to include(';') - end - end -end diff --git a/spec/uploadcare/param/webhook_signature_verifier_spec.rb b/spec/uploadcare/param/webhook_signature_verifier_spec.rb deleted file mode 100644 index 2f988c2a..00000000 --- a/spec/uploadcare/param/webhook_signature_verifier_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'param/simple_auth_header' - -module Uploadcare - RSpec.describe Param::WebhookSignatureVerifier do - subject(:signature_valid?) { described_class.valid?(**params) } - - let(:webhook_body) do - { - payload_hash: '1844671900', - data: { - uuid: 'f08d7c8a-2971-42e0-ab01-780d9039b40b', - image_info: { - color_mode: 'RGB', format: 'JPEG', height: 168, width: 300, orientation: nil, dpi: nil, - geo_location: nil, datetime_original: nil, sequence: false - }, - video_info: nil, - content_info: { - mime: { - mime: 'image/jpeg', type: 'image', subtype: 'jpeg' - }, - video: nil, - image: { - color_mode: 'RGB', format: 'JPEG', height: 168, width: 300, orientation: nil, dpi: nil, - geo_location: nil, datetime_original: nil, sequence: false - } - }, - mime_type: 'image/jpeg', - original_filename: 'download.jpeg', - size: 10_603, - is_image: true, - is_ready: true, - datetime_removed: nil, - datetime_stored: nil, - datetime_uploaded: nil, - original_file_url: 'https://ucarecdn.com/f08d7c8a-2971-42e0-ab01-780d9039b40b/download.jpeg', - url: '', - source: nil, - variations: nil, - rekognition_info: nil - }, - hook: { - id: 889_783, - project_id: 123_681, - target: 'https://6f48-188-232-175-230.ngrok.io/posts', - event: 'file.uploaded', - is_active: true, - created_at: '2021-11-18T06:17:42.730459Z', - updated_at: '2021-11-18T06:17:42.730459Z' - }, - file: 'https://ucarecdn.com/f08d7c8a-2971-42e0-ab01-780d9039b40b/download.jpeg' - }.to_json - end - - let(:params) do - { - webhook_body: webhook_body, - signing_secret: '12345X', - x_uc_signature_header: 'v1=9b31c7dd83fdbf4a2e12b19d7f2b9d87d547672a325b9492457292db4f513c70' - } - end - - context 'when a signature is valid' do - it 'returns true' do - expect(signature_valid?).to be_truthy - end - end - - context 'when a signature is invalid' do - let(:params) { super().merge(signing_secret: '12345') } - - it 'returns false' do - expect(signature_valid?).to be_falsey - end - end - end -end diff --git a/spec/uploadcare/resources/add_ons_spec.rb b/spec/uploadcare/resources/add_ons_spec.rb new file mode 100644 index 00000000..29614586 --- /dev/null +++ b/spec/uploadcare/resources/add_ons_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::AddOns do + let(:uuid) { '1bac376c-aa7e-4356-861b-dd2657b5bfd2' } + let(:request_id) { 'd1fb31c6-ed34-4e21-bdc3-4f1485f58e21' } + let(:add_ons_client) { instance_double(Uploadcare::AddOnsClient) } + + before do + allow(described_class).to receive(:add_ons_client).and_return(add_ons_client) + end + + describe '.aws_rekognition_detect_labels' do + let(:response_body) { { 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' } } + + before do + allow(add_ons_client).to receive(:aws_rekognition_detect_labels).with(uuid).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the request_id' do + result = described_class.aws_rekognition_detect_labels(uuid) + expect(result).to be_a(described_class) + expect(result.request_id).to eq('8db3c8b4-2dea-4146-bcdb-63387e2b33c1') + end + end + + describe '.aws_rekognition_detect_labels_status' do + let(:response_body) { { 'status' => 'in_progress' } } + + before do + allow(add_ons_client).to receive(:aws_rekognition_detect_labels_status).with(request_id).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the status' do + result = described_class.aws_rekognition_detect_labels_status(request_id) + expect(result).to be_a(described_class) + expect(result.status).to eq('in_progress') + end + end + + describe '.aws_rekognition_detect_moderation_labels' do + let(:response_body) { { 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' } } + + before do + allow(add_ons_client).to receive(:aws_rekognition_detect_moderation_labels).with(uuid).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the request_id' do + result = described_class.aws_rekognition_detect_moderation_labels(uuid) + expect(result).to be_a(described_class) + expect(result.request_id).to eq(response_body['request_id']) + end + end + + describe '.check_aws_rekognition_detect_moderation_labels_status' do + let(:response_body) { { 'status' => 'in_progress' } } + + before do + allow(add_ons_client).to receive(:aws_rekognition_detect_moderation_labels_status).with(request_id).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the status' do + result = described_class.check_aws_rekognition_detect_moderation_labels_status(request_id) + expect(result).to be_a(described_class) + expect(result.status).to eq('in_progress') + end + end + + describe '.uc_clamav_virus_scan' do + let(:params) { { purge_infected: true } } + let(:response_body) { { 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' } } + + before do + allow(add_ons_client).to receive(:uc_clamav_virus_scan).with(uuid, params).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the request_id' do + result = described_class.uc_clamav_virus_scan(uuid, params) + expect(result).to be_a(described_class) + expect(result.request_id).to eq('8db3c8b4-2dea-4146-bcdb-63387e2b33c1') + end + end + + describe '.uc_clamav_virus_scan_status' do + let(:response_body) { { 'status' => 'in_progress' } } + + before do + allow(add_ons_client).to receive(:uc_clamav_virus_scan_status).with(request_id).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the status' do + result = described_class.uc_clamav_virus_scan_status(request_id) + expect(result).to be_a(described_class) + expect(result.status).to eq('in_progress') + end + end + + describe '.remove_bg' do + let(:params) { { crop: true, type_level: '2' } } + let(:response_body) { { 'request_id' => '8db3c8b4-2dea-4146-bcdb-63387e2b33c1' } } + + before do + allow(add_ons_client).to receive(:remove_bg).with(uuid, params).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the request_id' do + result = described_class.remove_bg(uuid, params) + expect(result).to be_a(described_class) + expect(result.request_id).to eq('8db3c8b4-2dea-4146-bcdb-63387e2b33c1') + end + end + + describe '.remove_bg_status' do + let(:response_body) { { 'status' => 'done', 'result' => { 'file_id' => '21975c81-7f57-4c7a-aef9-acfe28779f78' } } } + + before do + allow(add_ons_client).to receive(:remove_bg_status).with(request_id).and_return(response_body) + end + + it 'returns an instance of AddOns and assigns the status and result' do + result = described_class.remove_bg_status(request_id) + expect(result).to be_a(described_class) + expect(result.status).to eq('done') + expect(result.result['file_id']).to eq('21975c81-7f57-4c7a-aef9-acfe28779f78') + end + end +end diff --git a/spec/uploadcare/resources/base_resource_spec.rb b/spec/uploadcare/resources/base_resource_spec.rb new file mode 100644 index 00000000..e1f7530f --- /dev/null +++ b/spec/uploadcare/resources/base_resource_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::BaseResource do + let(:config) { Uploadcare::Configuration.new(public_key: 'test_public', secret_key: 'test_secret') } + + # Create a test resource class + let(:test_resource_class) do + Class.new(described_class) do + attr_accessor :uuid, :size, :is_ready, :metadata + end + end + + describe '#initialize' do + context 'with attributes and config' do + let(:attributes) do + { + uuid: '12345-67890', + size: 1024, + is_ready: true, + metadata: { key: 'value' } + } + end + + let(:resource) { test_resource_class.new(attributes, config) } + + it 'assigns the configuration' do + expect(resource.config).to eq(config) + end + + it 'assigns all attributes' do + expect(resource.uuid).to eq('12345-67890') + expect(resource.size).to eq(1024) + expect(resource.is_ready).to be(true) + expect(resource.metadata).to eq({ key: 'value' }) + end + end + + context 'with default configuration' do + before do + allow(Uploadcare).to receive(:configuration).and_return(config) + end + + let(:resource) { test_resource_class.new(uuid: 'test-uuid') } + + it 'uses the default configuration' do + expect(resource.config).to eq(config) + end + + it 'assigns attributes' do + expect(resource.uuid).to eq('test-uuid') + end + end + + context 'with unknown attributes' do + let(:attributes) do + { + uuid: '12345', + unknown_attribute: 'value', + another_unknown: 123 + } + end + + let(:resource) { test_resource_class.new(attributes, config) } + + it 'ignores unknown attributes' do + expect(resource.uuid).to eq('12345') + expect(resource).not_to respond_to(:unknown_attribute) + expect(resource).not_to respond_to(:another_unknown) + end + + it 'does not raise error' do + expect { resource }.not_to raise_error + end + end + + context 'with empty attributes' do + let(:resource) { test_resource_class.new({}, config) } + + it 'creates resource without errors' do + expect(resource).to be_a(test_resource_class) + expect(resource.uuid).to be_nil + expect(resource.size).to be_nil + end + end + + context 'with nil attributes' do + let(:resource) { test_resource_class.new(nil, config) } + + it 'handles nil gracefully' do + expect { resource }.to raise_error(NoMethodError) + end + end + end + + describe '#rest_client' do + let(:resource) { test_resource_class.new({}, config) } + + it 'returns a RestClient instance' do + expect(resource.send(:rest_client)).to be_a(Uploadcare::RestClient) + end + + it 'memoizes the rest client' do + client1 = resource.send(:rest_client) + client2 = resource.send(:rest_client) + expect(client1).to be(client2) + end + + it 'uses the resource configuration' do + rest_client = resource.send(:rest_client) + expect(rest_client.instance_variable_get(:@config)).to eq(config) + end + end + + describe '#assign_attributes' do + let(:resource) { test_resource_class.new({}, config) } + + it 'assigns multiple attributes' do + resource.send(:assign_attributes, { uuid: 'new-uuid', size: 2048 }) + expect(resource.uuid).to eq('new-uuid') + expect(resource.size).to eq(2048) + end + + it 'only assigns attributes with setters' do + resource.send(:assign_attributes, { uuid: 'test', non_existent: 'value' }) + expect(resource.uuid).to eq('test') + end + + it 'handles boolean attributes' do + resource.send(:assign_attributes, { is_ready: false }) + expect(resource.is_ready).to be(false) + end + + it 'handles complex attributes' do + complex_data = { nested: { data: [1, 2, 3] } } + resource.send(:assign_attributes, { metadata: complex_data }) + expect(resource.metadata).to eq(complex_data) + end + end + + describe 'inheritance' do + let(:child_class) do + Class.new(test_resource_class) do + attr_accessor :custom_field + + def custom_method + 'custom' + end + end + end + + let(:child_resource) { child_class.new({ uuid: 'child-uuid', custom_field: 'custom' }, config) } + + it 'inherits initialization behavior' do + expect(child_resource.uuid).to eq('child-uuid') + expect(child_resource.custom_field).to eq('custom') + end + + it 'inherits rest_client access' do + expect(child_resource.send(:rest_client)).to be_a(Uploadcare::RestClient) + end + + it 'can override methods' do + expect(child_resource.custom_method).to eq('custom') + end + end + + describe 'edge cases' do + context 'with string keys in attributes' do + let(:attributes) { { 'uuid' => 'string-key-uuid', 'size' => 512 } } + let(:resource) { test_resource_class.new(attributes, config) } + + it 'does not assign string keys' do + expect(resource.uuid).to be_nil + expect(resource.size).to be_nil + end + end + + context 'with mixed key types' do + let(:attributes) { { uuid: 'symbol-uuid', 'size' => 1024 } } + let(:resource) { test_resource_class.new(attributes, config) } + + it 'only assigns symbol keys' do + expect(resource.uuid).to eq('symbol-uuid') + expect(resource.size).to be_nil + end + end + + context 'with attribute writer that raises error' do + let(:error_class) do + Class.new(described_class) do + attr_reader :value + + def value=(val) + raise ArgumentError, 'Invalid value' if val == 'bad' + + @value = val + end + end + end + + it 'propagates the error' do + expect { error_class.new({ value: 'bad' }, config) }.to raise_error(ArgumentError, 'Invalid value') + end + end + end +end diff --git a/spec/uploadcare/resources/batch_file_result_spec.rb b/spec/uploadcare/resources/batch_file_result_spec.rb new file mode 100644 index 00000000..e1f41d2a --- /dev/null +++ b/spec/uploadcare/resources/batch_file_result_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::BatchFileResult do + let(:file_data) { { 'uuid' => SecureRandom.uuid, 'original_filename' => 'file.jpg' } } + let(:response) do + { + status: 200, + result: [file_data], + problems: [{ 'some-uuid': 'Missing in the project' }] + } + end + let(:config) { Uploadcare.configuration } + let(:result) { [file_data] } + + subject do + described_class.new( + **response, + config: config + ) + end + + it 'initializes with status, result, and problems' do + expect(subject.status).to eq(200) + expect(subject.result).to all(be_an(Uploadcare::File)) + expect(subject.problems).to eq(response[:problems]) + end +end diff --git a/spec/uploadcare/resources/document_converter_spec.rb b/spec/uploadcare/resources/document_converter_spec.rb new file mode 100644 index 00000000..adf3b7b4 --- /dev/null +++ b/spec/uploadcare/resources/document_converter_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::DocumentConverter do + let(:uuid) { SecureRandom.uuid } + let(:token) { '32921143' } + subject(:document_converter) { described_class.new } + + describe '#info' do + let(:response_body) do + { + 'format' => { 'name' => 'pdf', 'conversion_formats' => [{ 'name' => 'txt' }] }, + 'converted_groups' => { 'pdf' => 'group_uuid~1' }, + 'error' => nil + } + end + + subject { document_converter.info(uuid) } + + before do + allow_any_instance_of(Uploadcare::DocumentConverterClient).to receive(:info).with(uuid).and_return(response_body) + end + + it 'assigns attributes correctly' do + expect(subject.format['name']).to eq('pdf') + expect(subject.converted_groups['pdf']).to eq('group_uuid~1') + end + end + + describe '.convert_document' do + let(:document_params) { { uuid: 'doc_uuid', format: :pdf } } + let(:options) { { store: true, save_in_group: false } } + let(:response_body) do + { + 'problems' => {}, + 'result' => [ + { + 'original_source' => 'doc_uuid/document/-/format/pdf/', + 'token' => 445_630_631, + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d' + } + ] + } + end + + subject { described_class.convert_document(document_params, options) } + + before do + allow_any_instance_of(Uploadcare::DocumentConverterClient).to receive(:convert_document) + .with(['doc_uuid/document/-/format/pdf/'], options).and_return(response_body) + end + + it { is_expected.to eq(response_body) } + end + + describe '#status' do + let(:response_body) do + { + 'status' => 'processing', + 'error' => nil, + 'result' => { 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d' } + } + end + + subject { document_converter.fetch_status(token) } + + before do + allow_any_instance_of(Uploadcare::DocumentConverterClient).to receive(:status).with(token).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::DocumentConverter) } + + it 'assigns attributecorrectly' do + expect(subject.status).to eq(response_body['status']) + expect(subject.result['uuid']).to eq(response_body['result']['uuid']) + end + end +end diff --git a/spec/uploadcare/resources/file_metadata_spec.rb b/spec/uploadcare/resources/file_metadata_spec.rb new file mode 100644 index 00000000..92c076cc --- /dev/null +++ b/spec/uploadcare/resources/file_metadata_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::FileMetadata do + subject(:file_metadata) { described_class.new } + + let(:uuid) { 'file-uuid' } + let(:key) { 'custom-key' } + let(:value) { 'custom-value' } + let(:response_body) { { key => value } } + + describe '#show' do + before do + allow_any_instance_of(Uploadcare::FileMetadataClient).to receive(:show).with(uuid, key).and_return(value) + end + + it 'retrieves a specific metadata key value' do + result = file_metadata.show(uuid, key) + expect(result).to eq(value) + end + end + + describe '#update' do + before do + allow_any_instance_of(Uploadcare::FileMetadataClient).to receive(:update).with(uuid, key, value).and_return(value) + end + + it 'updates a specific metadata key value' do + result = file_metadata.update(uuid, key, value) + expect(result).to eq(value) + end + end + + describe '#delete' do + before do + allow_any_instance_of(Uploadcare::FileMetadataClient).to receive(:delete).with(uuid, key).and_return(nil) + end + + it 'deletes a specific metadata key' do + result = file_metadata.delete(uuid, key) + expect(result).to be_nil + end + end +end diff --git a/spec/uploadcare/resources/file_spec.rb b/spec/uploadcare/resources/file_spec.rb new file mode 100644 index 00000000..9276d85d --- /dev/null +++ b/spec/uploadcare/resources/file_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::File do + let(:uuid) { SecureRandom.uuid } + let(:response_body) do + { + datetime_removed: nil, + datetime_stored: '2018-11-26T12:49:10.477888Z', + datetime_uploaded: '2018-11-26T12:49:09.945335Z', + variations: nil, + is_image: true, + is_ready: true, + mime_type: 'image/jpeg', + original_file_url: "https://ucarecdn.com/#{uuid}/file.jpg", + original_filename: 'file.jpg', + size: 642, + url: "https://api.uploadcare.com/files/#{uuid}/", + uuid: uuid + } + end + subject(:file) { described_class.new(uuid: uuid) } + + describe '#list' do + let(:response_body) do + { + 'next' => nil, + 'previous' => nil, + 'per_page' => 10, + 'results' => [ + { + 'uuid' => 'file_uuid_1', + 'original_filename' => 'file1.jpg', + 'size' => 12_345, + 'datetime_uploaded' => '2023-10-01T12:00:00Z', + 'url' => 'https://ucarecdn.com/file_uuid_1/', + 'is_image' => true, + 'mime_type' => 'image/jpeg' + }, + { + 'uuid' => 'file_uuid_2', + 'original_filename' => 'file2.png', + 'size' => 67_890, + 'datetime_uploaded' => '2023-10-02T12:00:00Z', + 'url' => 'https://ucarecdn.com/file_uuid_2/', + 'is_image' => true, + 'mime_type' => 'image/png' + } + ], + 'total' => 2 + } + end + subject { described_class.list } + + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:get).with('files/', {}).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::PaginatedCollection) } + it { expect(subject.resources.size).to eq(2) } + + it 'returns FileList containing File Resources' do + first_file = subject.resources.first + expect(first_file).to be_a(described_class) + expect(first_file.uuid).to eq('file_uuid_1') + expect(first_file.original_filename).to eq('file1.jpg') + expect(first_file.size).to eq(12_345) + expect(first_file.datetime_uploaded).to eq('2023-10-01T12:00:00Z') + expect(first_file.url).to eq('https://ucarecdn.com/file_uuid_1/') + expect(first_file.is_image).to be true + expect(first_file.mime_type).to eq('image/jpeg') + end + end + + describe '#store' do + subject { file.store } + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:put).with("/files/#{uuid}/storage/").and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::File) } + it { expect(subject.uuid).to eq(uuid) } + end + + describe '#delete' do + subject { file.delete } + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:delete).with(uuid).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::File) } + it { expect(subject.uuid).to eq(uuid) } + end + + describe '#info' do + subject { file.info } + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:get).with("/files/#{uuid}/", {}).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::File) } + it { expect(subject.uuid).to eq(uuid) } + end + + describe 'Batch Operations' do + let(:uuids) { [SecureRandom.uuid, SecureRandom.uuid] } + let(:file_data) { { 'uuid' => SecureRandom.uuid, 'original_filename' => 'file.jpg' } } + let(:response_body) do + { + status: 200, + result: [file_data], + problems: [{ 'some-uuid': 'Missing in the project' }] + } + end + + describe '.batch_store' do + subject { described_class.batch_store(uuids) } + + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:put).with('/files/storage/', uuids).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::BatchFileResult) } + it { expect(subject.status).to eq(200) } + it { expect(subject.result.first).to be_a(Uploadcare::File) } + it { expect(subject.problems).not_to be_empty } + end + + describe '.batch_delete' do + subject { described_class.batch_delete(uuids) } + + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:del).with('/files/storage/', uuids).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::BatchFileResult) } + it { expect(subject.status).to eq(200) } + it { expect(subject.result.first).to be_a(Uploadcare::File) } + it { expect(subject.problems).not_to be_empty } + end + end + + describe '#local_copy' do + let(:options) { { store: 'true', metadata: { key: 'value' } } } + let(:source) { SecureRandom.uuid } + let(:response_body) do + { + 'type' => 'file', + 'result' => { + 'uuid' => source, + 'original_filename' => 'copy_of_file.jpg', + 'size' => 12_345, + 'datetime_uploaded' => '2023-10-10T12:00:00Z', + 'url' => "https://ucarecdn.com/#{source}/", + 'is_image' => true, + 'mime_type' => 'image/jpeg' + } + } + end + + subject { file.local_copy(options) } + + before do + file.uuid = source + allow_any_instance_of(Uploadcare::FileClient).to receive(:local_copy) + .with(source, options) + .and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::File) } + + it { expect(subject.uuid).to eq(source) } + it { expect(subject.original_filename).to eq('copy_of_file.jpg') } + it { expect(subject.size).to eq(12_345) } + end + + describe '#remote_copy' do + let(:source) { SecureRandom.uuid } + let(:target) { 'custom_storage_name' } + let(:s3_url) { 's3://mybucket/copied_file.jpg' } + let(:options) { { make_public: false, pattern: '${default}' } } + let(:response_body) { { 'type' => 'url', 'result' => s3_url } } + + subject { file.remote_copy(target, options) } + + before do + file.uuid = source + allow_any_instance_of(Uploadcare::FileClient).to receive(:remote_copy) + .with(source, target, options) + .and_return(response_body) + end + + it { is_expected.to be_a(String) } + it { is_expected.to eq(s3_url) } + end + + # There is a duplication of assertions for both class and instance methods + # Can be refactored later + describe '.local_copy' do + let(:source) { SecureRandom.uuid } + let(:options) { { store: 'true', metadata: { key: 'value' } } } + let(:response_body) do + { + 'type' => 'file', + 'result' => { + 'uuid' => source, + 'original_filename' => 'copy_of_file.jpg', + 'size' => 12_345, + 'datetime_uploaded' => '2023-10-10T12:00:00Z', + 'url' => "https://ucarecdn.com/#{source}/", + 'is_image' => true, + 'mime_type' => 'image/jpeg' + } + } + end + + subject { described_class.local_copy(source, options) } + + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:local_copy) + .with(source, options) + .and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::File) } + + it { expect(subject.uuid).to eq(source) } + it { expect(subject.original_filename).to eq('copy_of_file.jpg') } + it { expect(subject.size).to eq(12_345) } + end + + # There is a duplication of assertions for both class and instance methods + # Can be refactored later + describe '.remote_copy' do + let(:source) { SecureRandom.uuid } + let(:target) { 'custom_storage_name' } + let(:s3_url) { 's3://mybucket/copied_file.jpg' } + let(:options) { { make_public: false, pattern: '${default}' } } + let(:response_body) { { 'type' => 'url', 'result' => s3_url } } + + subject { described_class.remote_copy(source, target, options) } + + before do + allow_any_instance_of(Uploadcare::FileClient).to receive(:remote_copy) + .with(source, target, options) + .and_return(response_body) + end + + it { is_expected.to be_a(String) } + it { is_expected.to eq(s3_url) } + end +end diff --git a/spec/uploadcare/resources/group_spec.rb b/spec/uploadcare/resources/group_spec.rb new file mode 100644 index 00000000..ab4bb6f7 --- /dev/null +++ b/spec/uploadcare/resources/group_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Group do + describe '#list' do + let(:response_body) do + { + 'next' => nil, + 'previous' => nil, + 'per_page' => 10, + 'results' => [ + { + 'id' => 'group_uuid_1~2', + 'datetime_created' => '2023-11-01T12:49:10.477888Z', + 'files_count' => 2, + 'cdn_url' => 'https://ucarecdn.com/group_uuid_1~2/', + 'url' => 'https://api.uploadcare.com/groups/group_uuid_1~2/' + }, + { + 'id' => 'group_uuid_2~3', + 'datetime_created' => '2023-11-02T14:49:10.477888Z', + 'files_count' => 3, + 'cdn_url' => 'https://ucarecdn.com/group_uuid_2~3/', + 'url' => 'https://api.uploadcare.com/groups/group_uuid_2~3/' + } + ], + 'total' => 2 + } + end + subject { described_class.list } + + before do + allow_any_instance_of(Uploadcare::GroupClient).to receive(:list).and_return(response_body) + end + + it { is_expected.to be_a(Uploadcare::PaginatedCollection) } + it { expect(subject.resources.size).to eq(2) } + + it 'returns GroupList containing Group Resources' do + first_group = subject.resources.first + expect(first_group).to be_a(described_class) + expect(first_group.id).to eq('group_uuid_1~2') + expect(first_group.datetime_created).to eq('2023-11-01T12:49:10.477888Z') + expect(first_group.files_count).to eq(2) + expect(first_group.cdn_url).to eq('https://ucarecdn.com/group_uuid_1~2/') + expect(first_group.url).to eq('https://api.uploadcare.com/groups/group_uuid_1~2/') + end + end + + let(:uuid) { 'group_uuid_1~2' } + let(:response_body) do + { + 'id' => uuid, + 'datetime_created' => '2023-11-01T12:49:10.477888Z', + 'files_count' => 2, + 'cdn_url' => "https://ucarecdn.com/#{uuid}/", + 'url' => "https://api.uploadcare.com/groups/#{uuid}/", + 'files' => [ + { + 'uuid' => 'file_uuid_1', + 'datetime_uploaded' => '2023-11-01T12:49:09.945335Z', + 'is_image' => true, + 'mime_type' => 'image/jpeg', + 'original_filename' => 'file1.jpg', + 'size' => 12_345 + } + ] + } + end + + subject(:group) { described_class.new({}) } + + describe '#info' do + before do + allow_any_instance_of(Uploadcare::GroupClient).to receive(:info).with(uuid).and_return(response_body) + end + + it 'fetches and assigns group info' do + result = group.info(uuid) + + expect(result.id).to eq(uuid) + expect(result.datetime_created).to eq('2023-11-01T12:49:10.477888Z') + expect(result.files_count).to eq(2) + expect(result.cdn_url).to eq("https://ucarecdn.com/#{uuid}/") + expect(result.url).to eq("https://api.uploadcare.com/groups/#{uuid}/") + expect(result.files.first['uuid']).to eq('file_uuid_1') + expect(result.files.first['original_filename']).to eq('file1.jpg') + expect(result.files.first['size']).to eq(12_345) + end + end +end diff --git a/spec/uploadcare/resources/paginated_collection_spec.rb b/spec/uploadcare/resources/paginated_collection_spec.rb new file mode 100644 index 00000000..1379364e --- /dev/null +++ b/spec/uploadcare/resources/paginated_collection_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::PaginatedCollection do + let(:resource_class) { Uploadcare::File } + let(:client) { double('client', config: double('config')) } + + let(:resources) do + [ + { uuid: '1', size: 100 }, + { uuid: '2', size: 200 } + ].map { |attrs| resource_class.new(attrs, client.config) } + end + + let(:collection_params) do + { + resources: resources, + next_page: 'https://api.uploadcare.com/files/?limit=2&offset=2', + previous_page: nil, + per_page: 2, + total: 10, + client: client, + resource_class: resource_class + } + end + + let(:collection) { described_class.new(collection_params) } + + describe '#initialize' do + it 'sets all attributes' do + expect(collection.resources).to eq(resources) + expect(collection.next_page_url).to eq('https://api.uploadcare.com/files/?limit=2&offset=2') + expect(collection.previous_page_url).to be_nil + expect(collection.per_page).to eq(2) + expect(collection.total).to eq(10) + expect(collection.client).to eq(client) + expect(collection.resource_class).to eq(resource_class) + end + end + + describe '#each' do + it 'yields each resource' do + yielded_resources = collection.map { |resource| resource } + expect(yielded_resources).to eq(resources) + end + + it 'returns an enumerator when no block given' do + expect(collection.each).to be_a(Enumerator) + end + + it 'is enumerable' do + expect(collection).to be_a(Enumerable) + expect(collection.map(&:uuid)).to eq(%w[1 2]) + end + end + + describe '#next_page' do + context 'when next_page_url exists' do + let(:next_page_response) do + { + 'results' => [ + { 'uuid' => '3', 'size' => 300 }, + { 'uuid' => '4', 'size' => 400 } + ], + 'next' => 'https://api.uploadcare.com/files/?limit=2&offset=4', + 'previous' => 'https://api.uploadcare.com/files/?limit=2&offset=0', + 'per_page' => 2, + 'total' => 10 + } + end + + before do + allow(client).to receive(:list).with({ 'limit' => '2', 'offset' => '2' }).and_return(next_page_response) + end + + it 'fetches the next page' do + next_page = collection.next_page + + expect(next_page).to be_a(described_class) + expect(next_page.resources.size).to eq(2) + expect(next_page.resources.first.uuid).to eq('3') + expect(next_page.resources.last.uuid).to eq('4') + expect(next_page.next_page_url).to eq('https://api.uploadcare.com/files/?limit=2&offset=4') + expect(next_page.previous_page_url).to eq('https://api.uploadcare.com/files/?limit=2&offset=0') + end + end + + context 'when next_page_url is nil' do + let(:collection_params) do + super().merge(next_page: nil) + end + + it 'returns nil' do + expect(collection.next_page).to be_nil + end + end + end + + describe '#previous_page' do + context 'when previous_page_url exists' do + let(:collection_params) do + super().merge(previous_page: 'https://api.uploadcare.com/files/?limit=2&offset=0') + end + + let(:previous_page_response) do + { + 'results' => [ + { 'uuid' => '0', 'size' => 50 } + ], + 'next' => 'https://api.uploadcare.com/files/?limit=2&offset=2', + 'previous' => nil, + 'per_page' => 2, + 'total' => 10 + } + end + + before do + allow(client).to receive(:list).with({ 'limit' => '2', 'offset' => '0' }).and_return(previous_page_response) + end + + it 'fetches the previous page' do + previous_page = collection.previous_page + + expect(previous_page).to be_a(described_class) + expect(previous_page.resources.size).to eq(1) + expect(previous_page.resources.first.uuid).to eq('0') + expect(previous_page.next_page_url).to eq('https://api.uploadcare.com/files/?limit=2&offset=2') + expect(previous_page.previous_page_url).to be_nil + end + end + + context 'when previous_page_url is nil' do + it 'returns nil' do + expect(collection.previous_page).to be_nil + end + end + end + + describe '#all' do + context 'with multiple pages' do + let(:page2_response) do + { + 'results' => [ + { 'uuid' => '3', 'size' => 300 }, + { 'uuid' => '4', 'size' => 400 } + ], + 'next' => 'https://api.uploadcare.com/files/?limit=2&offset=4', + 'previous' => 'https://api.uploadcare.com/files/?limit=2&offset=0', + 'per_page' => 2, + 'total' => 10 + } + end + + let(:page3_response) do + { + 'results' => [ + { 'uuid' => '5', 'size' => 500 } + ], + 'next' => nil, + 'previous' => 'https://api.uploadcare.com/files/?limit=2&offset=2', + 'per_page' => 2, + 'total' => 10 + } + end + + before do + allow(client).to receive(:list).with({ 'limit' => '2', 'offset' => '2' }).and_return(page2_response) + allow(client).to receive(:list).with({ 'limit' => '2', 'offset' => '4' }).and_return(page3_response) + end + + it 'fetches all resources from all pages' do + all_resources = collection.all + + expect(all_resources.size).to eq(5) + expect(all_resources.map(&:uuid)).to eq(%w[1 2 3 4 5]) + expect(all_resources.map(&:size)).to eq([100, 200, 300, 400, 500]) + end + + it 'returns a new array without modifying original resources' do + original_resources = collection.resources.dup + all_resources = collection.all + + expect(collection.resources).to eq(original_resources) + expect(all_resources).not_to be(collection.resources) + end + end + + context 'with single page' do + let(:collection_params) do + super().merge(next_page: nil) + end + + it 'returns only current page resources' do + all_resources = collection.all + + expect(all_resources.size).to eq(2) + expect(all_resources.map(&:uuid)).to eq(%w[1 2]) + end + end + + context 'with empty collection' do + let(:collection_params) do + super().merge(resources: [], next_page: nil) + end + + it 'returns empty array' do + expect(collection.all).to eq([]) + end + end + end + + describe '#extract_params_from_url' do + it 'extracts query parameters from URL' do + url = 'https://api.uploadcare.com/files/?limit=10&offset=20&stored=true' + params = collection.send(:extract_params_from_url, url) + + expect(params).to eq({ + 'limit' => '10', + 'offset' => '20', + 'stored' => 'true' + }) + end + + it 'handles URLs without query parameters' do + url = 'https://api.uploadcare.com/files/' + params = collection.send(:extract_params_from_url, url) + + expect(params).to eq({}) + end + + it 'handles complex query parameters' do + url = 'https://api.uploadcare.com/files/?ordering=-datetime_uploaded&removed=false' + params = collection.send(:extract_params_from_url, url) + + expect(params).to eq({ + 'ordering' => '-datetime_uploaded', + 'removed' => 'false' + }) + end + end + + describe 'edge cases' do + context 'with nil client' do + let(:collection_params) do + super().merge(client: nil) + end + + it 'raises error when trying to fetch pages' do + expect { collection.next_page }.to raise_error(NoMethodError) + end + end + + context 'with invalid URL' do + let(:collection_params) do + super().merge(next_page: 'not a valid url') + end + + it 'raises error when trying to fetch next page' do + expect { collection.next_page }.to raise_error(URI::InvalidURIError) + end + end + + context 'when API returns unexpected response' do + before do + allow(client).to receive(:list).and_return({}) + end + + it 'handles missing results gracefully' do + expect { collection.next_page }.to raise_error(NoMethodError) + end + end + end +end diff --git a/spec/uploadcare/resources/project_spec.rb b/spec/uploadcare/resources/project_spec.rb new file mode 100644 index 00000000..62713513 --- /dev/null +++ b/spec/uploadcare/resources/project_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Project do + describe '.show' do + let(:project_response) do + { + 'name' => 'Test Project', + 'pub_key' => 'public_key', + 'autostore_enabled' => true, + 'collaborators' => [ + { 'name' => 'John Doe', 'email' => 'john.doe@example.com' }, + { 'name' => 'Jane Smith', 'email' => 'jane.smith@example.com' } + ] + } + end + + before do + allow_any_instance_of(Uploadcare::ProjectClient).to receive(:show).and_return(project_response) + end + + it 'fetches project information and populates attributes' do + project = described_class.show + expect(project).to be_a(described_class) + expect(project.name).to eq('Test Project') + expect(project.pub_key).to eq('public_key') + expect(project.autostore_enabled).to be(true) + expect(project.collaborators).to be_an(Array) + expect(project.collaborators.first['name']).to eq('John Doe') + expect(project.collaborators.first['email']).to eq('john.doe@example.com') + end + end +end diff --git a/spec/uploadcare/resources/uploader_spec.rb b/spec/uploadcare/resources/uploader_spec.rb new file mode 100644 index 00000000..328c1bfa --- /dev/null +++ b/spec/uploadcare/resources/uploader_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Uploader do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'test_public_key', + secret_key: 'test_secret_key' + ) + end + + describe '.upload' do + context 'with a file path' do + let(:file_path) { File.join(File.dirname(__FILE__), '../../fixtures/kitten.jpeg') } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads a file' do + allow_any_instance_of(Uploadcare::UploaderClient).to receive(:upload_file).and_return(mock_response) + + result = described_class.upload(file_path, {}, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-123') + end + end + + context 'with a URL' do + let(:url) { 'https://example.com/image.jpg' } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads from URL' do + allow_any_instance_of(Uploadcare::UploaderClient).to receive(:upload_from_url).and_return(mock_response) + + result = described_class.upload(url, {}, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-123') + end + end + + context 'with an array of files' do + let(:files) { ['file1.jpg', 'file2.jpg'] } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads multiple files' do + allow_any_instance_of(Uploadcare::UploaderClient).to receive(:upload_file).and_return(mock_response) + + results = described_class.upload(files, {}, config) + expect(results).to be_an(Array) + expect(results.size).to eq(2) + expect(results.first).to be_a(Uploadcare::File) + end + end + end + + describe '.upload_file' do + let(:file_path) { File.join(File.dirname(__FILE__), '../../fixtures/kitten.jpeg') } + + context 'with small file' do + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uses regular upload' do + allow(File).to receive(:size).with(file_path).and_return(5 * 1024 * 1024) # 5MB + + uploader_client = instance_double(Uploadcare::UploaderClient) + expect(Uploadcare::UploaderClient).to receive(:new).and_return(uploader_client) + expect(uploader_client).to receive(:upload_file).with(file_path, {}).and_return(mock_response) + + result = described_class.upload_file(file_path, {}, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-123') + end + end + + context 'with large file' do + let(:mock_response) { { 'file' => 'file-uuid-456' } } + + it 'uses multipart upload' do + allow(File).to receive(:size).with(file_path).and_return(15 * 1024 * 1024) # 15MB + + multipart_client = instance_double(Uploadcare::MultipartUploadClient) + expect(Uploadcare::MultipartUploadClient).to receive(:new).and_return(multipart_client) + expect(multipart_client).to receive(:upload_file).with(file_path, {}).and_return(mock_response) + + result = described_class.upload_file(file_path, {}, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-456') + end + end + + context 'with File object' do + let(:file) { File.open(file_path) } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + after { file.close } + + it 'extracts path from File object' do + allow(File).to receive(:size).with(file_path).and_return(5 * 1024 * 1024) + allow_any_instance_of(Uploadcare::UploaderClient).to receive(:upload_file).and_return(mock_response) + + result = described_class.upload_file(file, {}, config) + expect(result).to be_a(Uploadcare::File) + end + end + end + + describe '.upload_files' do + let(:files) { ['file1.jpg', 'file2.jpg'] } + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'uploads multiple files' do + allow(described_class).to receive(:upload_file).and_return(Uploadcare::File.new({ 'uuid' => 'file-uuid-123' }, config)) + + results = described_class.upload_files(files, {}, config) + expect(results).to be_an(Array) + expect(results.size).to eq(2) + expect(results.all? { |r| r.is_a?(Uploadcare::File) }).to be true + end + end + + describe '.upload_from_url' do + let(:url) { 'https://example.com/image.jpg' } + + context 'synchronous upload' do + let(:mock_response) { { 'file' => 'file-uuid-123' } } + + it 'returns uploaded file' do + uploader_client = instance_double(Uploadcare::UploaderClient) + expect(Uploadcare::UploaderClient).to receive(:new).and_return(uploader_client) + expect(uploader_client).to receive(:upload_from_url).with(url, {}).and_return(mock_response) + + result = described_class.upload_from_url(url, {}, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-123') + end + end + + context 'asynchronous upload' do + let(:mock_response) { { 'token' => 'upload-token-123' } } + + it 'returns token info with status checker' do + uploader_client = instance_double(Uploadcare::UploaderClient) + expect(Uploadcare::UploaderClient).to receive(:new).and_return(uploader_client) + expect(uploader_client).to receive(:upload_from_url).with(url, {}).and_return(mock_response) + + result = described_class.upload_from_url(url, {}, config) + expect(result).to be_a(Hash) + expect(result[:token]).to eq('upload-token-123') + expect(result[:status]).to eq('pending') + expect(result[:check_status]).to respond_to(:call) + end + end + end + + describe '.check_upload_status' do + let(:token) { 'upload-token-123' } + let(:uploader_client) { instance_double(Uploadcare::UploaderClient) } + + before do + expect(Uploadcare::UploaderClient).to receive(:new).and_return(uploader_client) + end + + context 'when upload succeeds' do + let(:mock_response) do + { 'status' => 'success', 'file' => 'file-uuid-123' } + end + + it 'returns uploaded file' do + expect(uploader_client).to receive(:check_upload_status).with(token).and_return(mock_response) + + result = described_class.check_upload_status(token, config) + expect(result).to be_a(Uploadcare::File) + expect(result.uuid).to eq('file-uuid-123') + end + end + + context 'when upload fails' do + let(:mock_response) do + { 'status' => 'error', 'error' => 'Upload failed' } + end + + it 'raises error' do + expect(uploader_client).to receive(:check_upload_status).with(token).and_return(mock_response) + + expect { described_class.check_upload_status(token, config) } + .to raise_error(Uploadcare::RequestError, 'Upload failed') + end + end + + context 'when upload is pending' do + let(:mock_response) do + { 'status' => 'pending', 'done' => 50, 'total' => 100 } + end + + it 'returns status info' do + expect(uploader_client).to receive(:check_upload_status).with(token).and_return(mock_response) + + result = described_class.check_upload_status(token, config) + expect(result).to eq(mock_response) + end + end + end + + describe '.file_info' do + let(:uuid) { 'file-uuid-123' } + let(:mock_response) { { 'uuid' => uuid, 'size' => 12_345 } } + + it 'retrieves file info without storing' do + uploader_client = instance_double(Uploadcare::UploaderClient) + expect(Uploadcare::UploaderClient).to receive(:new).and_return(uploader_client) + expect(uploader_client).to receive(:file_info).with(uuid).and_return(mock_response) + + result = described_class.file_info(uuid, config) + expect(result).to eq(mock_response) + end + end +end diff --git a/spec/uploadcare/resources/video_converter_spec.rb b/spec/uploadcare/resources/video_converter_spec.rb new file mode 100644 index 00000000..3e73addd --- /dev/null +++ b/spec/uploadcare/resources/video_converter_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::VideoConverter do + let(:uuid) { SecureRandom.uuid } + let(:token) { '32921143' } + subject(:video_converter) { described_class.new } + + describe '#convert' do + let(:video_params) { { uuid: 'video_uuid', format: :mp4, quality: :lighter } } + let(:options) { { store: true } } + let(:response_body) do + { + 'problems' => {}, + 'result' => [ + { + 'original_source' => 'video_uuid/video/-/format/mp4/-/quality/lighter/', + 'token' => 445_630_631, + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d', + 'thumbnails_group_uuid' => '575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1' + } + ] + } + end + + subject { described_class.convert(video_params, options) } + + before do + allow_any_instance_of(Uploadcare::VideoConverterClient).to receive(:convert_video) + .with(['video_uuid/video/-/format/mp4/-/quality/lighter/'], options).and_return(response_body) + end + + it { is_expected.to eq(response_body) } + + it 'returns the correct conversion details' do + result = subject['result'].first + expect(result['uuid']).to eq('d52d7136-a2e5-4338-9f45-affbf83b857d') + expect(result['token']).to eq(445_630_631) + expect(result['thumbnails_group_uuid']).to eq('575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1') + end + end + + describe '#status' do + let(:response_body) do + { + 'status' => 'processing', + 'error' => nil, + 'result' => { + 'uuid' => 'd52d7136-a2e5-4338-9f45-affbf83b857d', + 'thumbnails_group_uuid' => '575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1' + } + } + end + + subject { video_converter.status(token) } + + before do + allow_any_instance_of(Uploadcare::VideoConverterClient).to receive(:status).with(token).and_return(response_body) + end + + it 'returns an instance of VideoConverter' do + result = video_converter.fetch_status(token) + expect(result).to be_a(Uploadcare::VideoConverter) + end + + it 'assigns attributes correctly' do + result = video_converter.fetch_status(token) + expect(result.status).to eq('processing') + expect(result.result['uuid']).to eq('d52d7136-a2e5-4338-9f45-affbf83b857d') + expect(result.result['thumbnails_group_uuid']).to eq('575ed4e8-f4e8-4c14-a58b-1527b6d9ee46~1') + end + end +end diff --git a/spec/uploadcare/resources/webhook_spec.rb b/spec/uploadcare/resources/webhook_spec.rb new file mode 100644 index 00000000..68a54e81 --- /dev/null +++ b/spec/uploadcare/resources/webhook_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.describe Uploadcare::Webhook do + describe '.list' do + let(:response_body) do + [ + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => 'file.infected', + 'target_url' => 'http://example.com/hooks/receiver', + 'is_active' => true, + 'signing_secret' => '7kMVZivndx0ErgvhRKAr', + 'version' => '0.7' + } + ] + end + + before do + allow_any_instance_of(Uploadcare::WebhookClient).to receive(:list_webhooks).and_return(response_body) + end + + it 'returns a list of webhooks as Webhook objects' do + webhooks = described_class.list + expect(webhooks).to all(be_a(described_class)) + expect(webhooks.first.id).to eq(1) + expect(webhooks.first.event).to eq('file.infected') + expect(webhooks.first.target_url).to eq('http://example.com/hooks/receiver') + end + end + describe '.create' do + let(:target_url) { 'https://example.com/hooks' } + let(:event) { 'file.uploaded' } + let(:is_active) { true } + let(:signing_secret) { 'secret' } + let(:version) { '0.7' } + let(:response_body) do + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => 'file.uploaded', + 'target_url' => 'https://example.com/hooks', + 'is_active' => true, + 'signing_secret' => 'secret', + 'version' => '0.7' + } + end + + before do + allow_any_instance_of(Uploadcare::WebhookClient).to receive(:create_webhook) + .with(target_url, event, is_active, signing_secret, version) + .and_return(response_body) + end + + it 'creates a new webhook' do + webhook = described_class.create(target_url, event, is_active: is_active, signing_secret: signing_secret, version: version) + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq(1) + expect(webhook.event).to eq('file.uploaded') + expect(webhook.target_url).to eq('https://example.com/hooks') + end + end + describe '.update' do + let(:webhook_id) { 1 } + let(:target_url) { 'https://example.com/hooks/updated' } + let(:event) { 'file.uploaded' } + let(:is_active) { true } + let(:signing_secret) { 'updated-secret' } + let(:response_body) do + { + 'id' => 1, + 'project' => 13, + 'created' => '2016-04-27T11:49:54.948615Z', + 'updated' => '2016-04-27T12:04:57.819933Z', + 'event' => 'file.uploaded', + 'target_url' => 'https://example.com/hooks/updated', + 'is_active' => true, + 'signing_secret' => 'updated-secret', + 'version' => '0.7' + } + end + + before do + allow_any_instance_of(Uploadcare::WebhookClient).to receive(:update_webhook) + .with(webhook_id, target_url, event, is_active: is_active, signing_secret: signing_secret) + .and_return(response_body) + end + + it 'returns the updated webhook as an object' do + webhook = described_class.update(webhook_id, target_url, event, is_active: is_active, signing_secret: signing_secret) + expect(webhook).to be_a(described_class) + expect(webhook.id).to eq(1) + expect(webhook.target_url).to eq(target_url) + expect(webhook.event).to eq(event) + expect(webhook.is_active).to eq(true) + expect(webhook.signing_secret).to eq(signing_secret) + end + end + describe '.delete' do + let(:target_url) { 'https://example.com/hooks' } + + before do + allow_any_instance_of(Uploadcare::WebhookClient).to receive(:delete_webhook) + .with(target_url).and_return(nil) + end + + it 'deletes the webhook successfully' do + expect { described_class.delete(target_url) }.not_to raise_error + end + end +end diff --git a/spec/uploadcare/signed_url_generators/akamai_generator_spec.rb b/spec/uploadcare/signed_url_generators/akamai_generator_spec.rb index c4efde57..64d8b363 100644 --- a/spec/uploadcare/signed_url_generators/akamai_generator_spec.rb +++ b/spec/uploadcare/signed_url_generators/akamai_generator_spec.rb @@ -1,77 +1,177 @@ # frozen_string_literal: true require 'spec_helper' -require 'signed_url_generators/akamai_generator' -module Uploadcare - RSpec.describe SignedUrlGenerators::AkamaiGenerator do - subject { described_class.new(cdn_host: 'example.com', secret_key: secret_key) } +RSpec.describe Uploadcare::SignedUrlGenerators::AkamaiGenerator do + let(:cdn_host) { 'cdn.example.com' } + let(:secret_key) { '0123456789abcdef0123456789abcdef' } + let(:generator) { described_class.new(cdn_host: cdn_host, secret_key: secret_key) } - let(:default_ttl) { 300 } - let(:default_algorithm) { 'sha256' } - let(:uuid) { 'a7d5645e-5cd7-4046-819f-a6a2933bafe3' } - let(:unixtime) { '1649343600' } - let(:secret_key) { 'secret_key' } + describe '#generate_url' do + let(:uuid) { '12345678-1234-1234-1234-123456789012' } - describe '#generate_url' do + context 'with default expiration' do before do - allow(Time).to receive(:now).and_return(unixtime) + # Freeze time for predictable results + allow(Time).to receive(:now).and_return(Time.at(1_609_459_200)) # 2021-01-01 00:00:00 UTC end - context 'when acl not present' do - it 'returns correct url' do - expected_url = 'https://example.com/a7d5645e-5cd7-4046-819f-a6a2933bafe3/?token=exp=1649343900~acl=/a7d5645e-5cd7-4046-819f-a6a2933bafe3/~hmac=d8b4919d595805fd8923258bb647065b7d7201dad8f475d6f5c430e3bffa8122' - expect(subject.generate_url(uuid)).to eq expected_url - end + it 'generates a signed URL with 5 minute expiration' do + url = generator.generate_url(uuid) + + expect(url).to start_with("https://#{cdn_host}/#{uuid}/") + expect(url).to include('token=') + expect(url).to include('exp=1609459500') # 5 minutes later + expect(url).to include("acl=/#{uuid}/") + expect(url).to include('hmac=') end - context 'when uuid with transformations' do - let(:uuid) { "#{super()}/-/resize/640x/other/transformations/" } + it 'generates different URLs for different UUIDs' do + url1 = generator.generate_url('uuid-1') + url2 = generator.generate_url('uuid-2') - it 'returns correct url' do - expected_url = 'https://example.com/a7d5645e-5cd7-4046-819f-a6a2933bafe3/-/resize/640x/other/transformations/?token=exp=1649343900~acl=/a7d5645e-5cd7-4046-819f-a6a2933bafe3/-/resize/640x/other/transformations/~hmac=64dd1754c71bf194fcc81d49c413afeb3bbe0e6d703ed4c9b30a8a48c1782f53' - expect(subject.generate_url(uuid)).to eq expected_url - end + expect(url1).not_to eq(url2) end + end + + context 'with custom expiration' do + it 'uses provided expiration time' do + custom_expiration = 1_609_462_800 # 2021-01-01 01:00:00 UTC + url = generator.generate_url(uuid, custom_expiration) - context 'when acl present' do - it 'returns correct url' do - acl = '/*/' - expected_url = 'https://example.com/a7d5645e-5cd7-4046-819f-a6a2933bafe3/?token=exp=1649343900~acl=/*/~hmac=984914950bccbfe22f542aa1891300fb2624def1208452335fc72520c934c4c3' - expect(subject.generate_url(uuid, acl)).to eq expected_url - end + expect(url).to include("exp=#{custom_expiration}") end + end + + context 'with different secret keys' do + it 'generates different signatures' do + generator1 = described_class.new(cdn_host: cdn_host, secret_key: '1111111111111111') + generator2 = described_class.new(cdn_host: cdn_host, secret_key: '2222222222222222') + + # Use same time for both + time = Time.at(1_609_459_200) + allow(Time).to receive(:now).and_return(time) + + url1 = generator1.generate_url(uuid) + url2 = generator2.generate_url(uuid) + + # Extract HMAC from URLs + hmac1 = url1.match(/hmac=([^&]+)/)[1] + hmac2 = url2.match(/hmac=([^&]+)/)[1] + + expect(hmac1).not_to eq(hmac2) + end + end - context 'when uuid not valid' do - it 'returns exception' do - expect { subject.generate_url(SecureRandom.hex) }.to raise_error ArgumentError - end + describe 'token format' do + before do + allow(Time).to receive(:now).and_return(Time.at(1_609_459_200)) end - context 'when wildcard is true' do - it 'returns correct url' do - expected_url = 'https://example.com/a7d5645e-5cd7-4046-819f-a6a2933bafe3/?token=exp=1649343900~acl=/a7d5645e-5cd7-4046-819f-a6a2933bafe3/*~hmac=6f032220422cdaea5fe0b58f9dcf681269591bb5d1231aa1c4a38741d7cc2fe5' - expect(subject.generate_url(uuid, nil, wildcard: true)).to eq expected_url - end + it 'includes all required token components' do + url = generator.generate_url(uuid) + token_match = url.match(/token=(.+)$/) + expect(token_match).not_to be_nil + + token = token_match[1] + expect(token).to match(/^exp=\d+~acl=.+~hmac=.+$/) end - context 'works with group' do - let(:uuid) { '83a8994a-e0b4-4091-9a10-5a847298e493~4' } + it 'uses URL-safe base64 encoding for HMAC' do + url = generator.generate_url(uuid) + hmac = url.match(/hmac=([^&]+)/)[1] - it 'returns correct url' do - expected_url = 'https://example.com/83a8994a-e0b4-4091-9a10-5a847298e493~4/?token=exp=1649343900~acl=/83a8994a-e0b4-4091-9a10-5a847298e493%7e4/*~hmac=f4d4c5da93324dffa2b5bb42d8a6cc693789077212cbdf599fe3220b9d37749d' - expect(subject.generate_url(uuid, nil, wildcard: true)).to eq expected_url - end + # URL-safe base64 should not contain +, /, or = + expect(hmac).not_to include('+') + expect(hmac).not_to include('/') + expect(hmac).not_to include('=') end + end - context 'works with nth file type notation for files within a group' do - let(:uuid) { '83a8994a-e0b4-4091-9a10-5a847298e493~4/nth/0/-/crop/250x250/1000,1000' } + describe 'ACL path' do + it 'includes trailing slash in ACL' do + url = generator.generate_url(uuid) + expect(url).to include("acl=/#{uuid}/") + end - it 'returns correct url' do - expected_url = 'https://example.com/83a8994a-e0b4-4091-9a10-5a847298e493~4/nth/0/-/crop/250x250/1000,1000/?token=exp=1649343900~acl=/83a8994a-e0b4-4091-9a10-5a847298e493%7e4/nth/0/-/crop/250x250/1000,1000/*~hmac=d483cfa64cffe617c1cc72d6f1d3287a74d27cb608bbf08dc07d3d61e29cd4be' - expect(subject.generate_url(uuid, nil, wildcard: true)).to eq expected_url - end + it 'uses UUID as path component' do + special_uuid = 'test-uuid-with-special-chars' + url = generator.generate_url(special_uuid) + expect(url).to include("acl=/#{special_uuid}/") end end end + + describe '#generate_token' do + it 'creates HMAC-SHA256 signature' do + acl = '/test-uuid/' + expiration = 1_609_459_200 + + token = generator.send(:generate_token, acl, expiration) + + expect(token).to be_a(String) + expect(token).not_to be_empty + end + + it 'generates consistent tokens for same inputs' do + acl = '/test-uuid/' + expiration = 1_609_459_200 + + token1 = generator.send(:generate_token, acl, expiration) + token2 = generator.send(:generate_token, acl, expiration) + + expect(token1).to eq(token2) + end + end + + describe '#hex_to_binary' do + it 'converts hex string to binary' do + hex = '48656c6c6f' # "Hello" in hex + binary = generator.send(:hex_to_binary, hex) + + expect(binary).to eq('Hello') + end + + it 'handles lowercase hex' do + hex = 'abcdef' + binary = generator.send(:hex_to_binary, hex) + + expect(binary.bytes).to eq([171, 205, 239]) + end + + it 'handles uppercase hex' do + hex = 'ABCDEF' + binary = generator.send(:hex_to_binary, hex) + + expect(binary.bytes).to eq([171, 205, 239]) + end + end + + describe 'integration' do + it 'generates valid URL structure' do + allow(Time).to receive(:now).and_return(Time.at(1_609_459_200)) + + url = generator.generate_url('test-file-uuid') + uri = URI.parse(url) + + expect(uri.scheme).to eq('https') + expect(uri.host).to eq(cdn_host) + expect(uri.path).to eq('/test-file-uuid/') + expect(uri.query).to match(/^token=exp=\d+~acl=.+~hmac=.+$/) + end + + it 'generates URLs that expire at the correct time' do + current_time = Time.at(1_609_459_200) + allow(Time).to receive(:now).and_return(current_time) + + url = generator.generate_url('uuid') + + # Extract expiration from URL + exp_match = url.match(/exp=(\d+)/) + expect(exp_match).not_to be_nil + + expiration = Time.at(exp_match[1].to_i) + expect(expiration).to eq(current_time + 300) # 5 minutes + end + end end diff --git a/spec/uploadcare/signed_url_generators/base_generator_spec.rb b/spec/uploadcare/signed_url_generators/base_generator_spec.rb new file mode 100644 index 00000000..cdafad24 --- /dev/null +++ b/spec/uploadcare/signed_url_generators/base_generator_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::SignedUrlGenerators::BaseGenerator do + let(:cdn_host) { 'cdn.example.com' } + let(:secret_key) { 'test-secret-key' } + let(:generator) { described_class.new(cdn_host: cdn_host, secret_key: secret_key) } + + describe '#initialize' do + it 'sets cdn_host' do + expect(generator.cdn_host).to eq(cdn_host) + end + + it 'sets secret_key' do + expect(generator.secret_key).to eq(secret_key) + end + end + + describe '#generate_url' do + it 'raises NotImplementedError' do + expect { generator.generate_url('uuid') }.to raise_error( + NotImplementedError, + 'Subclasses must implement generate_url method' + ) + end + + it 'raises NotImplementedError with expiration parameter' do + expect { generator.generate_url('uuid', 1_234_567_890) }.to raise_error( + NotImplementedError, + 'Subclasses must implement generate_url method' + ) + end + end + + describe '#build_url' do + it 'builds HTTPS URL with path' do + url = generator.send(:build_url, '/path/to/resource') + expect(url).to eq('https://cdn.example.com/path/to/resource') + end + + it 'builds URL with query parameters' do + url = generator.send(:build_url, '/path', { token: 'abc123', exp: '1234567890' }) + + uri = URI.parse(url) + expect(uri.scheme).to eq('https') + expect(uri.host).to eq('cdn.example.com') + expect(uri.path).to eq('/path') + + params = URI.decode_www_form(uri.query).to_h + expect(params).to eq({ + 'token' => 'abc123', + 'exp' => '1234567890' + }) + end + + it 'handles empty query parameters' do + url = generator.send(:build_url, '/path', {}) + expect(url).to eq('https://cdn.example.com/path') + expect(url).not_to include('?') + end + + it 'properly encodes query parameters' do + url = generator.send(:build_url, '/path', { + 'special chars' => 'value with spaces', + 'symbols' => '!@#$%' + }) + + uri = URI.parse(url) + params = URI.decode_www_form(uri.query).to_h + + expect(params['special chars']).to eq('value with spaces') + expect(params['symbols']).to eq('!@#$%') + end + + it 'handles paths with leading slash' do + url = generator.send(:build_url, '/leading/slash') + expect(url).to eq('https://cdn.example.com/leading/slash') + end + + it 'handles paths without leading slash' do + url = generator.send(:build_url, 'no/leading/slash') + expect(url).to eq('https://cdn.example.com/no/leading/slash') + end + end + + describe 'inheritance' do + let(:custom_generator_class) do + Class.new(described_class) do + def generate_url(uuid, expiration = nil) + expiration ||= Time.now.to_i + 300 + build_url("/#{uuid}/", { token: "test-#{expiration}" }) + end + end + end + + let(:custom_generator) { custom_generator_class.new(cdn_host: cdn_host, secret_key: secret_key) } + + it 'allows subclasses to implement generate_url' do + url = custom_generator.generate_url('test-uuid') + + expect(url).to start_with('https://cdn.example.com/test-uuid/') + expect(url).to include('token=test-') + end + + it 'inherits initialization' do + expect(custom_generator.cdn_host).to eq(cdn_host) + expect(custom_generator.secret_key).to eq(secret_key) + end + + it 'can use build_url from parent' do + allow(Time).to receive(:now).and_return(Time.at(1_609_459_200)) + + url = custom_generator.generate_url('uuid', 1_609_459_500) + expect(url).to eq('https://cdn.example.com/uuid/?token=test-1609459500') + end + end + + describe 'with different CDN hosts' do + it 'handles hosts with subdomains' do + generator = described_class.new( + cdn_host: 'static.cdn.example.com', + secret_key: 'key' + ) + + url = generator.send(:build_url, '/path') + expect(url).to eq('https://static.cdn.example.com/path') + end + + it 'handles hosts with ports' do + generator = described_class.new( + cdn_host: 'cdn.example.com:8443', + secret_key: 'key' + ) + + url = generator.send(:build_url, '/path') + expect(url).to eq('https://cdn.example.com:8443/path') + end + + it 'handles IP addresses' do + generator = described_class.new( + cdn_host: '192.168.1.1', + secret_key: 'key' + ) + + url = generator.send(:build_url, '/path') + expect(url).to eq('https://192.168.1.1/path') + end + end +end diff --git a/spec/uploadcare/throttle_handler_spec.rb b/spec/uploadcare/throttle_handler_spec.rb new file mode 100644 index 00000000..a527c2c9 --- /dev/null +++ b/spec/uploadcare/throttle_handler_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::ThrottleHandler do + let(:test_class) do + Class.new do + include Uploadcare::ThrottleHandler + end + end + + let(:handler) { test_class.new } + + before do + allow(Uploadcare).to receive(:configuration).and_return( + double('configuration', max_throttle_attempts: 5) + ) + end + + describe '#handle_throttling' do + context 'when block executes successfully' do + it 'returns the block result' do + result = handler.handle_throttling { 'success' } + expect(result).to eq('success') + end + + it 'executes block only once' do + call_count = 0 + handler.handle_throttling { call_count += 1 } + expect(call_count).to eq(1) + end + end + + context 'when block raises ThrottleError' do + let(:throttle_error) do + error = Uploadcare::ThrottleError.new('Rate limited') + allow(error).to receive(:timeout).and_return(0.01) # Short timeout for tests + error + end + + context 'and succeeds on retry' do + it 'retries and returns successful result' do + attempts = 0 + result = handler.handle_throttling do + attempts += 1 + raise throttle_error if attempts < 3 + + 'success after retries' + end + + expect(result).to eq('success after retries') + expect(attempts).to eq(3) + end + + it 'sleeps for the specified timeout' do + attempts = 0 + expect(handler).to receive(:sleep).with(0.01).twice + + handler.handle_throttling do + attempts += 1 + raise throttle_error if attempts < 3 + + 'success' + end + end + end + + context 'and fails all attempts' do + it 'raises ThrottleError after max attempts' do + attempts = 0 + + expect do + handler.handle_throttling do + attempts += 1 + raise throttle_error + end + end.to raise_error(Uploadcare::ThrottleError, 'Rate limited') + + expect(attempts).to eq(5) # max_throttle_attempts + end + + it 'sleeps between each retry' do + expect(handler).to receive(:sleep).with(0.01).exactly(4).times + + expect do + handler.handle_throttling { raise throttle_error } + end.to raise_error(Uploadcare::ThrottleError) + end + end + + context 'with different max_throttle_attempts' do + before do + allow(Uploadcare).to receive(:configuration).and_return( + double('configuration', max_throttle_attempts: 3) + ) + end + + it 'respects configured max attempts' do + attempts = 0 + + expect do + handler.handle_throttling do + attempts += 1 + raise throttle_error + end + end.to raise_error(Uploadcare::ThrottleError) + + expect(attempts).to eq(3) + end + + it 'sleeps correct number of times' do + expect(handler).to receive(:sleep).with(0.01).exactly(2).times + + expect do + handler.handle_throttling { raise throttle_error } + end.to raise_error(Uploadcare::ThrottleError) + end + end + + context 'with max_throttle_attempts set to 1' do + before do + allow(Uploadcare).to receive(:configuration).and_return( + double('configuration', max_throttle_attempts: 1) + ) + end + + it 'does not retry' do + attempts = 0 + + expect do + handler.handle_throttling do + attempts += 1 + raise throttle_error + end + end.to raise_error(Uploadcare::ThrottleError) + + expect(attempts).to eq(1) + end + + it 'does not sleep' do + expect(handler).not_to receive(:sleep) + + expect do + handler.handle_throttling { raise throttle_error } + end.to raise_error(Uploadcare::ThrottleError) + end + end + end + + context 'when block raises other errors' do + it 'does not retry on non-ThrottleError' do + attempts = 0 + + expect do + handler.handle_throttling do + attempts += 1 + raise StandardError, 'Other error' + end + end.to raise_error(StandardError, 'Other error') + + expect(attempts).to eq(1) + end + + it 'does not catch the error' do + expect do + handler.handle_throttling { raise ArgumentError, 'Bad argument' } + end.to raise_error(ArgumentError, 'Bad argument') + end + end + + context 'with varying timeout values' do + it 'uses timeout from each error instance' do + attempts = 0 + timeouts = [0.01, 0.02, 0.03] + + timeouts.each_with_index do |timeout, index| + error = Uploadcare::ThrottleError.new("Attempt #{index + 1}") + allow(error).to receive(:timeout).and_return(timeout) + + expect(handler).to receive(:sleep).with(timeout).ordered if index < timeouts.length - 1 + end + + result = handler.handle_throttling do + attempts += 1 + if attempts <= timeouts.length + error = Uploadcare::ThrottleError.new("Attempt #{attempts}") + allow(error).to receive(:timeout).and_return(timeouts[attempts - 1]) + raise error + end + 'success' + end + + expect(result).to eq('success') + end + end + + context 'with block that modifies state' do + it 'preserves state changes across retries' do + counter = 0 + + result = handler.handle_throttling do + counter += 1 + raise throttle_error if counter < 3 + + counter + end + + expect(result).to eq(3) + expect(counter).to eq(3) + end + end + end +end diff --git a/spec/uploadcare/url_builder_spec.rb b/spec/uploadcare/url_builder_spec.rb new file mode 100644 index 00000000..9084a911 --- /dev/null +++ b/spec/uploadcare/url_builder_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::UrlBuilder do + let(:config) do + Uploadcare::Configuration.new( + cdn_base: 'https://ucarecdn.com/' + ) + end + let(:uuid) { 'dc99200d-9bd6-4b43-bfa9-aa7bfaefca40' } + + subject(:builder) { described_class.new(uuid, config) } + + describe '#initialize' do + context 'with UUID string' do + it 'constructs base URL correctly' do + expect(builder.base_url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40') + end + end + + context 'with File object' do + let(:file) { Uploadcare::File.new({ uuid: uuid }, config) } + subject(:builder) { described_class.new(file, config) } + + it 'constructs base URL from file' do + expect(builder.base_url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40') + end + end + + context 'with full URL' do + let(:url) { 'https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/' } + subject(:builder) { described_class.new(url, config) } + + it 'uses the URL directly' do + expect(builder.base_url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40') + end + end + end + + describe 'resize operations' do + it 'builds resize with width and height' do + url = builder.resize(300, 200).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/resize/300x200/') + end + + it 'builds resize with width only' do + url = builder.resize_width(300).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/resize/300x/') + end + + it 'builds resize with height only' do + url = builder.resize_height(200).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/resize/x200/') + end + + it 'builds scale crop' do + url = builder.scale_crop(300, 200).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/scale_crop/300x200/') + end + + it 'builds smart resize' do + url = builder.smart_resize(300, 200).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/scale_crop/300x200/smart/') + end + end + + describe 'crop operations' do + it 'builds basic crop' do + url = builder.crop(100, 100).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/crop/100x100/') + end + + it 'builds crop with offset' do + url = builder.crop(100, 100, offset_x: 10, offset_y: 20).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/crop/100x100/10,20/') + end + + it 'builds face crop' do + url = builder.crop_faces.url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/crop/faces/') + end + + it 'builds face crop with ratio' do + url = builder.crop_faces('16:9').url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/crop/faces/16:9/') + end + end + + describe 'format operations' do + it 'converts format' do + url = builder.format('webp').url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/format/webp/') + end + + it 'sets quality' do + url = builder.quality('smart').url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/quality/smart/') + end + + it 'enables progressive' do + url = builder.progressive.url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/progressive/yes/') + end + end + + describe 'effects and filters' do + it 'applies grayscale' do + url = builder.grayscale.url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/grayscale/') + end + + it 'applies blur' do + url = builder.blur(10).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/blur/10/') + end + + it 'applies rotation' do + url = builder.rotate(90).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/rotate/90/') + end + + it 'applies brightness' do + url = builder.brightness(50).url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/brightness/50/') + end + end + + describe 'chaining operations' do + it 'chains multiple operations' do + url = builder + .resize(300, 200) + .quality('smart') + .format('webp') + .grayscale + .url + + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/resize/300x200/-/quality/smart/-/format/webp/-/grayscale/') + end + end + + describe 'filename' do + it 'adds filename to URL' do + url = builder.resize(300, 200).filename('custom-name.jpg').url + expect(url).to eq('https://ucarecdn.com/dc99200d-9bd6-4b43-bfa9-aa7bfaefca40/-/resize/300x200/custom-name.jpg') + end + end + + describe 'aliases' do + it 'responds to to_s' do + expect(builder.resize(300, 200).to_s).to eq(builder.resize(300, 200).url) + end + + it 'responds to to_url' do + expect(builder.resize(300, 200).to_url).to eq(builder.resize(300, 200).url) + end + end +end diff --git a/spec/uploadcare/version_spec.rb b/spec/uploadcare/version_spec.rb new file mode 100644 index 00000000..f0518adb --- /dev/null +++ b/spec/uploadcare/version_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Uploadcare::VERSION do + it 'has a version number' do + expect(Uploadcare::VERSION).to eq('5.0.0') + end +end diff --git a/uploadcare-ruby.gemspec b/uploadcare-ruby.gemspec index 49376008..f4e9f265 100644 --- a/uploadcare-ruby.gemspec +++ b/uploadcare-ruby.gemspec @@ -2,7 +2,7 @@ lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'uploadcare/ruby/version' +require 'uploadcare/version' Gem::Specification.new do |spec| spec.name = 'uploadcare-ruby' @@ -41,12 +41,10 @@ Gem::Specification.new do |spec| end spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ['lib', 'lib/uploadcare', 'lib/uploadcare/rest'] + spec.require_paths = ['lib', 'lib/uploadcare'] spec.required_ruby_version = '>= 3.0' - spec.add_dependency 'mimemagic', '~> 0.4' - spec.add_dependency 'parallel', '~> 1.22' - spec.add_dependency 'retries', '~> 0.0' - spec.add_dependency 'uploadcare-api_struct', '>= 1.1', '< 2' + spec.add_dependency 'faraday', '~> 2.12' + spec.add_dependency 'zeitwerk', '~> 2.6.18' end