├── .rspec ├── lib ├── magento │ ├── version.rb │ ├── shared │ │ ├── item.rb │ │ ├── title.rb │ │ ├── total.rb │ │ ├── value.rb │ │ ├── payment.rb │ │ ├── shipping.rb │ │ ├── address.rb │ │ ├── filter.rb │ │ ├── option.rb │ │ ├── region.rb │ │ ├── stock_item.rb │ │ ├── condition.rb │ │ ├── currency.rb │ │ ├── tier_price.rb │ │ ├── filter_group.rb │ │ ├── product_link.rb │ │ ├── sort_order.rb │ │ ├── action_condition.rb │ │ ├── status_history.rb │ │ ├── available_regions.rb │ │ ├── billing_address.rb │ │ ├── extension_attribute.rb │ │ ├── media_gallery_entry.rb │ │ ├── shipping_assignment.rb │ │ ├── bundle_product_option.rb │ │ ├── payment_additional_info.rb │ │ ├── comment.rb │ │ ├── configurable_product_option.rb │ │ ├── applied_tax.rb │ │ ├── category_link.rb │ │ ├── custom_attribute.rb │ │ ├── item_applied_tax.rb │ │ └── search_criterium.rb │ ├── category.rb │ ├── country.rb │ ├── params.rb │ ├── tax_rate.rb │ ├── tax_rule.rb │ ├── polymorphic_model.rb │ ├── params │ │ ├── create_custom_attribute.rb │ │ ├── create_category.rb │ │ ├── create_product_link.rb │ │ ├── create_image.rb │ │ └── create_product.rb │ ├── errors.rb │ ├── import │ │ ├── image_finder.rb │ │ ├── csv_reader.rb │ │ ├── template │ │ │ └── products.csv │ │ ├── category.rb │ │ └── product.rb │ ├── import.rb │ ├── configuration.rb │ ├── sales_rule.rb │ ├── record_collection.rb │ ├── inventory.rb │ ├── model_mapper.rb │ ├── customer.rb │ ├── model.rb │ ├── request.rb │ ├── invoice.rb │ ├── guest_cart.rb │ ├── cart.rb │ ├── query.rb │ ├── order.rb │ └── product.rb └── magento.rb ├── spec ├── magento_spec.rb ├── spec_helper.rb ├── magento │ ├── order_spec.rb │ ├── invoice_spec.rb │ ├── cart_spec.rb │ ├── product_spec.rb │ └── core │ │ ├── model_mapper_spec.rb │ │ ├── record_collection_spec.rb │ │ ├── request_spec.rb │ │ ├── model_spec.rb │ │ └── query_spec.rb └── vcr_cassettes │ ├── order │ └── send_email.yml │ └── product │ └── find.yml ├── bin ├── setup └── console ├── Rakefile ├── Gemfile ├── .gitignore ├── magento.gemspec ├── .github └── workflows │ └── gem-push.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/magento/version.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | VERSION = '0.31.0' 3 | end -------------------------------------------------------------------------------- /lib/magento/shared/item.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Item; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/title.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Title; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/total.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Total; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/value.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Value; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/payment.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Payment; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/shipping.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Shipping; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/category.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Category < Model 3 | end 4 | end -------------------------------------------------------------------------------- /lib/magento/shared/address.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Address 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/filter.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Filter 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/option.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Option 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/region.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Region 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/stock_item.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class StockItem 3 | end 4 | end -------------------------------------------------------------------------------- /lib/magento/shared/condition.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Condition 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/currency.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Currency 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/tier_price.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class TierPrice 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/filter_group.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class FilterGroup 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/product_link.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class ProductLink 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/sort_order.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class SortOrder 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/action_condition.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class ActionCondition 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/status_history.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class StatusHistory 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/available_regions.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class AvailableRegion 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/billing_address.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class BillingAddress 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/extension_attribute.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class ExtensionAttribute 3 | end 4 | end -------------------------------------------------------------------------------- /lib/magento/shared/media_gallery_entry.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class MediaGalleryEntry 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/shipping_assignment.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class ShippingAssignment; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/bundle_product_option.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class BundleProductOption 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/shared/payment_additional_info.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class PaymentAdditionalInfo; end 3 | end 4 | -------------------------------------------------------------------------------- /lib/magento/shared/comment.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Comment 3 | include Magento::ModelParser 4 | end 5 | end -------------------------------------------------------------------------------- /lib/magento/shared/configurable_product_option.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class ConfigurableProductOption 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/magento/country.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Country < Model 3 | self.endpoint = 'directory/countries' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/magento/shared/applied_tax.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class AppliedTax 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/magento/shared/category_link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class CategoryLink 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/magento/shared/custom_attribute.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class CustomAttribute 3 | include Magento::ModelParser 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/magento/shared/item_applied_tax.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class ItemAppliedTax 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/magento_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento do 2 | it "has a version number" do 3 | expect(Magento::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/magento/params.rb: -------------------------------------------------------------------------------- 1 | require 'dry/struct' 2 | 3 | module Magento 4 | module Params 5 | module Type 6 | include Dry.Types() 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | Bundler::GemHelper.install_tasks name: 'magento' 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | -------------------------------------------------------------------------------- /lib/magento/shared/search_criterium.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class SearchCriterium 3 | include Magento::ModelParser 4 | attr_accessor :current_page, :filter_groups, :page_size 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in magento.gemspec 6 | gemspec 7 | 8 | gem 'byebug', '~> 11.1', '>= 11.1.3' 9 | gem 'rake', '~> 12.0' 10 | gem 'rspec', '~> 3.0' 11 | gem 'vcr' 12 | gem 'webmock' 13 | -------------------------------------------------------------------------------- /lib/magento/tax_rate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class TaxRate < Model 5 | self.primary_key = :id 6 | self.endpoint = 'taxRates' 7 | 8 | class << self 9 | protected 10 | 11 | def query 12 | Query.new(self, api_resource: 'taxRates/search') 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/magento/tax_rule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class TaxRule < Model 5 | self.primary_key = :id 6 | self.endpoint = 'taxRules' 7 | 8 | class << self 9 | protected 10 | 11 | def query 12 | Query.new(self, api_resource: 'taxRules/search') 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/magento/polymorphic_model.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class PolymorphicModel 3 | attr_reader :api_resource 4 | 5 | def initialize(model, api_resource) 6 | @model = model 7 | @api_resource = api_resource 8 | end 9 | 10 | def new 11 | @model.new 12 | end 13 | 14 | def build(attributes) 15 | @model.build(attributes) 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "magento" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/magento/params/create_custom_attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | module Params 5 | class CreateCustomAttribute < Dry::Struct 6 | attribute :code, Type::String 7 | attribute :value, Type::String 8 | 9 | def to_h 10 | { 11 | "attribute_code": code, 12 | "value": value 13 | } 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .DS_Store 4 | */.DS_Store 5 | .bundle 6 | .config 7 | coverage 8 | InstalledFiles 9 | lib/bundler/man 10 | rdoc 11 | spec/reports 12 | test/tmp 13 | test/version_tmp 14 | tmp 15 | *.swp 16 | vimsession 17 | Gemfile.lock 18 | 19 | # YARD artifacts 20 | .yardoc 21 | _yardoc 22 | doc/ 23 | test/vcr_cassettes 24 | 25 | .vscode 26 | .byebug_history 27 | /manual_test 28 | 29 | # rspec failure tracking 30 | .rspec_status 31 | -------------------------------------------------------------------------------- /lib/magento/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class MagentoError < StandardError 5 | attr_reader :status 6 | attr_reader :errors 7 | attr_reader :request 8 | 9 | def initialize(msg = '', status = 400, errors = nil, request = nil) 10 | @status = status 11 | @errors = errors 12 | @request = request 13 | super(msg) 14 | end 15 | end 16 | 17 | class NotFound < MagentoError; end 18 | end 19 | -------------------------------------------------------------------------------- /lib/magento/import/image_finder.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | module Import 3 | class ImageFinder 4 | EXTENTIONS = %w[jpg jpeg png webp gif].freeze 5 | 6 | def initialize(images_folder) 7 | @images_folder = images_folder 8 | end 9 | 10 | def find_by_name(name) 11 | prefix = "#{@images_folder}/#{name}" 12 | 13 | EXTENTIONS.map { |e| ["#{prefix}.#{e}", "#{prefix}.#{e.upcase}"] }.flatten 14 | .find { |file| File.exist?(file) } 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/magento/import.rb: -------------------------------------------------------------------------------- 1 | require_relative 'import/image_finder' 2 | require_relative 'import/csv_reader' 3 | require_relative 'import/category' 4 | require_relative 'import/product' 5 | 6 | module Magento 7 | module Import 8 | def self.from_csv(file, images_folder: nil, website_ids: [0]) 9 | products = CSVReader.new(file).get_products 10 | products = Category.new(products).associate 11 | Product.new(website_ids, images_folder).import(products) 12 | end 13 | 14 | def self.get_csv_template 15 | File.open(__dir__ + '/import/template/products.csv') 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/magento/params/create_category.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | module Params 5 | class CreateCategoria < Dry::Struct 6 | attribute :name, Type::String 7 | attribute :parent_id, Type::Integer.optional 8 | attribute :url, Type::String.optional.default(nil) 9 | attribute :is_active, Type::Bool.default(true) 10 | 11 | def to_h 12 | { 13 | name: name, 14 | parent_id: parent_id, 15 | is_active: is_active, 16 | custom_attributes: url ? [{attribute_code: 'url_key', value: url }] : nil 17 | }.compact 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/magento/import/csv_reader.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | require 'ostruct' 3 | 4 | module Magento 5 | module Import 6 | class CSVReader 7 | def initialize(csv_file) 8 | @csv = CSV.read(csv_file, col_sep: ';') 9 | end 10 | 11 | def get_products 12 | @csv[1..-1].map do |row| 13 | OpenStruct.new({ 14 | name: row[0], 15 | sku: row[1], 16 | ean: row[2], 17 | description: row[3], 18 | price: row[4], 19 | special_price: row[5], 20 | quantity: row[6], 21 | cat1: row[7], 22 | cat2: row[8], 23 | cat3: row[9], 24 | main_image: row[10] 25 | }) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /magento.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require 'magento/version' 7 | 8 | Gem::Specification.new do |s| 9 | s.name = 'magento' 10 | s.version = Magento::VERSION 11 | s.date = '2020-07-31' 12 | s.summary = 'Magento Ruby library' 13 | s.description = 'Magento Ruby library' 14 | s.files = `git ls-files`.split($/) 15 | s.authors = ["Wallas Faria"] 16 | s.email = 'wallasfaria@hotmail.com' 17 | s.homepage = 'https://github.com/WallasFaria/magento-ruby' 18 | s.require_paths = ['lib'] 19 | 20 | s.add_dependency 'http', '~> 4.4' 21 | s.add_dependency 'dry-inflector', '~> 0.2.0' 22 | s.add_dependency 'dry-struct' 23 | s.add_dependency 'activesupport' 24 | s.add_dependency 'mini_magick' 25 | end 26 | -------------------------------------------------------------------------------- /lib/magento/params/create_product_link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | module Params 5 | class CreateProductLink < Dry::Struct 6 | LinkType = Type::String.enum( 7 | 'related', 8 | 'upsell', 9 | 'crosssell', 10 | 'associated' 11 | ) 12 | 13 | attribute :link_type, LinkType 14 | attribute :linked_product_sku, Type::String 15 | attribute :linked_product_type, Magento::Params::CreateProduct::ProductTypes 16 | attribute :position, Type::Integer 17 | attribute :sku, Type::String 18 | 19 | def to_h 20 | { 21 | link_type: link_type, 22 | linked_product_sku: linked_product_sku, 23 | linked_product_type: linked_product_type, 24 | position: position, 25 | sku: sku 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'byebug' 3 | require 'magento' 4 | require 'vcr' 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = '.rspec_status' 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | end 17 | 18 | Magento.configure do |config| 19 | config.url = 'https://dev.superbomemcasa.com.br' 20 | end 21 | 22 | VCR.configure do |c| 23 | c.cassette_library_dir = 'spec/vcr_cassettes' 24 | c.hook_into :webmock 25 | c.configure_rspec_metadata! 26 | c.filter_sensitive_data('') { Magento.configuration.url } 27 | c.filter_sensitive_data('') { Magento.configuration.url.sub(/^http(s)?:\/\//, '') } 28 | c.filter_sensitive_data('') { Magento.configuration.token } 29 | end 30 | -------------------------------------------------------------------------------- /lib/magento/configuration.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Configuration 3 | attr_accessor :url, :open_timeout, :timeout, :token, :store, :product_image 4 | 5 | def initialize(url: nil, token: nil, store: nil) 6 | self.url = url || ENV['MAGENTO_URL'] 7 | self.open_timeout = 30 8 | self.timeout = 90 9 | self.token = token || ENV['MAGENTO_TOKEN'] 10 | self.store = store || ENV['MAGENTO_STORE'] || :all 11 | 12 | self.product_image = ProductImageConfiguration.new 13 | end 14 | 15 | def copy_with(params = {}) 16 | clone.tap do |config| 17 | params.each { |key, value| config.send("#{key}=", value) } 18 | end 19 | end 20 | end 21 | 22 | class ProductImageConfiguration 23 | attr_accessor :small_size, :medium_size, :large_size 24 | 25 | def initialize 26 | self.small_size = '200x200>' 27 | self.medium_size = '400x400>' 28 | self.large_size = '800x800>' 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/magento/sales_rule.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class SalesRule < Model 3 | self.primary_key = :rule_id 4 | self.entity_key = :rule 5 | self.endpoint = 'salesRules' 6 | 7 | # Example 8 | # rule = Magento::SalesRule.find(5) 9 | # rule.generate_coupon(quantity: 1, length: 10) 10 | # 11 | # @return {String[]} 12 | def generate_coupon(attributes) 13 | body = { couponSpec: { rule_id: id }.merge(attributes) } 14 | self.class.generate_coupon(body) 15 | end 16 | 17 | class << self 18 | # Example 19 | # Magento::SalesRule.generate_coupon( 20 | # couponSpec: { 21 | # rule_id: 5, 22 | # quantity: 1, 23 | # length: 10 24 | # } 25 | # ) 26 | # @return {String[]} 27 | def generate_coupon(attributes) 28 | request.post('coupons/generate', attributes).parse 29 | end 30 | 31 | protected def query 32 | Query.new(self, api_resource: 'salesRules/search') 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/magento/order_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Order do 2 | let(:magento_client) { request = Magento::Request.new } 3 | 4 | before do 5 | allow(Magento::Order).to receive(:request).and_return(magento_client) 6 | end 7 | 8 | describe 'send_email' do 9 | let(:order_id) { 11735 } 10 | let(:response) { double('Response', parse: true, status: 200) } 11 | 12 | describe 'class method' do 13 | it 'should request POST /orders/:id/emails' do 14 | expect(magento_client).to receive(:post) 15 | .with("orders/#{order_id}/emails") 16 | .and_return(response) 17 | 18 | result = Magento::Order.send_email(order_id) 19 | end 20 | 21 | it 'should return true' do 22 | VCR.use_cassette('order/send_email') do 23 | expect(Magento::Order.send_email(order_id)).to be(true).or be(false) 24 | end 25 | end 26 | end 27 | 28 | describe 'instance method' do 29 | it 'shuld call the class method with order_id' do 30 | expect(Magento::Order).to receive(:send_email).with(order_id) 31 | 32 | order = Magento::Order.build(id: order_id) 33 | result = order.send_email 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/gem-push.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem 2 | 3 | on: 4 | push: 5 | branches: [ release ] 6 | 7 | jobs: 8 | build: 9 | name: Build + Publish 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Ruby 2.6 15 | uses: actions/setup-ruby@v1 16 | with: 17 | ruby-version: 2.6.x 18 | 19 | - name: Publish to GPR 20 | run: | 21 | mkdir -p $HOME/.gem 22 | touch $HOME/.gem/credentials 23 | chmod 0600 $HOME/.gem/credentials 24 | printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 25 | sed "s/'magento'/'magento-ruby'/" magento.gemspec > magento-ruby.gemspec 26 | gem build magento-ruby.gemspec 27 | gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem 28 | env: 29 | GEM_HOST_API_KEY: "Bearer ${{secrets.GITHUB_TOKEN}}" 30 | OWNER: ${{ github.repository_owner }} 31 | 32 | - name: Publish to RubyGems 33 | run: | 34 | mkdir -p $HOME/.gem 35 | touch $HOME/.gem/credentials 36 | chmod 0600 $HOME/.gem/credentials 37 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 38 | gem build magento.gemspec 39 | gem push magento-[0-9]*.gem 40 | env: 41 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 42 | -------------------------------------------------------------------------------- /lib/magento/import/template/products.csv: -------------------------------------------------------------------------------- 1 | name;sku;gtin;description;price;special price;quantity;category level 1;category level 2;category level 3;main image url 2 | Apple iPhone 11 Pro;iphone-11-pro;9999999999;"Description: New Apple iPhone 11 Pro Max 64/256/512GB Space Gray Midnight Green Silver Gold\nIncludes all new accessories\n\niPhone is unlocked to work on any GSM carrier!\n\nNo warranty - Warranty can be purchased through squaretrade\n\nShipping: We Normally Ship Out Same or Next Business Day Via USPS. \nDelivery Time Varies Especially During Holidays.\nWe Do NOT Ship Out On Saturday/Sunday\nWe Are Not Responsible For Late Packages, But We Will Find Out If There Are Any Issues.\nWe Only Ship To PayPal Confirmed Address.\nPick Up Also Available In Queens NY (Message For Details)\nPriority Mail USPS Free (1-4 Business Days; Not Guaranteed Service)\nExpress Overnight USPS $25 (Guaranteed By USPS Overnight To Most Places) \n\nAdditional return policy details: \nAs a leading seller of electronics, we want to guarantee that each transaction ends with a five star experience. That's why we offer a FREE no questions asked 30-day return policy. Please message us for more details.\n\nSales Tax:\nNew York orders will have a sales tax of 8.875% add to your cost";1099.99;999.00;45;Technology;Smartphone;Apple;"https://store.storeimages.cdn-apple.com/4982/as-images.apple.com/is/iphone-11-pro-select-2019-family?wid=882&hei=1058&fmt=jpeg&qlt=80&op_usm=0.5,0.5&.v=1586586488946" 3 | -------------------------------------------------------------------------------- /lib/magento/record_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Magento 6 | class RecordCollection 7 | attr_reader :items, :search_criteria, :total_count 8 | extend Forwardable 9 | 10 | def initialize(items:, total_count: nil, search_criteria: nil) 11 | @items = items || [] 12 | @total_count = total_count || @items.size 13 | @search_criteria = search_criteria || Magento::SearchCriterium.new 14 | end 15 | 16 | def_delegators :@search_criteria, :current_page, :filter_groups, :page_size 17 | 18 | def_delegators :@items, :count, :length, :size, :first, :last, :[], 19 | :find, :each, :each_with_index, :sample, :map, :select, 20 | :filter, :reject, :collect, :take, :take_while, :sort, 21 | :sort_by, :reverse_each, :reverse, :all?, :any?, :none?, 22 | :one?, :empty? 23 | 24 | alias per page_size 25 | 26 | def last_page? 27 | current_page * page_size >= total_count 28 | end 29 | 30 | # 31 | # Returns the {number} of the next page or {nil} 32 | # when the current_page is the last page 33 | def next_page 34 | current_page + 1 unless last_page? 35 | end 36 | 37 | class << self 38 | def from_magento_response(response, model:, iterable_field: 'items') 39 | Magento::RecordCollection.new( 40 | items: response[iterable_field]&.map { |item| model.build(item) }, 41 | total_count: response['total_count'], 42 | search_criteria: Magento::SearchCriterium.build(response['search_criteria']) 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/magento/invoice_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Invoice do 2 | let(:magento_client) { Magento::Request.new } 3 | 4 | before do 5 | allow(Magento::Invoice).to receive(:request).and_return(magento_client) 6 | end 7 | 8 | describe 'void' do 9 | let(:invoice_id) { 123 } 10 | let(:response) { double('Response', parse: true, status: 200) } 11 | 12 | describe 'class method' do 13 | it 'sends POST to /invoices/:id/void' do 14 | expect(magento_client).to receive(:post) 15 | .with("invoices/#{invoice_id}/void") 16 | .and_return(response) 17 | 18 | Magento::Invoice.void(invoice_id) 19 | end 20 | end 21 | 22 | describe 'instance method' do 23 | it 'calls the class method with invoice id' do 24 | expect(Magento::Invoice).to receive(:void).with(invoice_id) 25 | 26 | invoice = Magento::Invoice.build(entity_id: invoice_id) 27 | invoice.void 28 | end 29 | end 30 | end 31 | 32 | describe 'refund' do 33 | let(:invoice_id) { 123 } 34 | let(:response) { double('Response', parse: 1) } 35 | 36 | describe 'class method' do 37 | it 'sends POST to /invoice/:id/refund' do 38 | expect(magento_client).to receive(:post) 39 | .with("invoice/#{invoice_id}/refund", nil) 40 | .and_return(response) 41 | 42 | Magento::Invoice.refund(invoice_id) 43 | end 44 | end 45 | 46 | describe 'instance method' do 47 | it 'calls the class method with invoice id' do 48 | expect(Magento::Invoice).to receive(:refund).with(invoice_id, nil) 49 | 50 | invoice = Magento::Invoice.build(entity_id: invoice_id) 51 | invoice.refund 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/magento/import/category.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | module Import 3 | class Category 4 | def initialize(products) 5 | @products = products 6 | @category_root = Magento::Category.all 7 | @cats = @category_root.children_data 8 | end 9 | 10 | def associate 11 | @products.each do |prod| 12 | cat1 = find_or_create(name: prod.cat1, parent: @category_root) if prod.cat1 13 | cat2 = find_or_create(name: prod.cat2, parent: cat1) if prod.cat2 14 | cat3 = find_or_create(name: prod.cat3, parent: cat2) if prod.cat3 15 | 16 | prod.cat1, prod.cat2, prod.cat3 = cat1&.id, cat2&.id, cat3&.id 17 | 18 | @cats.push(*[cat1, cat2, cat3].compact) 19 | end 20 | end 21 | 22 | private 23 | 24 | def find_or_create(name:, parent:) 25 | find(name, cats: @cats, parent_id: parent.id) || create(name, parent: parent) 26 | end 27 | 28 | def find(name, cats:, parent_id:) 29 | cats.each do |cat| 30 | return cat if cat.name == name && cat.parent_id == parent_id 31 | 32 | if cat.respond_to?(:children_data) && cat.children_data&.size.to_i > 0 33 | result = find(name, cats: cat.children_data, parent_id: parent_id) 34 | return result if result 35 | end 36 | end 37 | nil 38 | end 39 | 40 | def create(name, parent:) 41 | params = Magento::Params::CreateCategoria.new( 42 | name: name, 43 | parent_id: parent.id, 44 | url: "#{Magento.configuration.store}-#{parent.id}-#{name.parameterize}" 45 | ) 46 | 47 | Magento::Category.create(params.to_h).tap { |c| puts "Create: #{c.id} => #{c.name}" } 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/magento/cart_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Cart do 2 | let(:magento_client) { Magento::Request.new } 3 | 4 | before do 5 | allow(Magento::Cart).to receive(:request).and_return(magento_client) 6 | end 7 | 8 | describe '.order' do 9 | it 'accepts string keyed attributes' do 10 | attributes = { 11 | 'cartId' => '123', 12 | 'paymentMethod' => { method: 'cashondelivery' }, 13 | 'email' => 'customer@example.com' 14 | } 15 | response = double('Response', parse: 'order_id', status: 200) 16 | 17 | expect(magento_client).to receive(:put) 18 | .with('carts/123/order', { 19 | cartId: '123', 20 | paymentMethod: { method: 'cashondelivery' }, 21 | email: 'customer@example.com' 22 | }) 23 | .and_return(response) 24 | 25 | expect(Magento::Cart.order(attributes)).to eql('order_id') 26 | end 27 | end 28 | end 29 | 30 | RSpec.describe Magento::GuestCart do 31 | let(:magento_client) { Magento::Request.new } 32 | 33 | before do 34 | allow(Magento::GuestCart).to receive(:request).and_return(magento_client) 35 | end 36 | 37 | describe '.payment_information' do 38 | it 'accepts string keyed attributes' do 39 | attributes = { 40 | 'cartId' => 'abc123', 41 | 'paymentMethod' => { method: 'checkmo' }, 42 | 'email' => 'guest@example.com' 43 | } 44 | response = double('Response', parse: 'order_id', status: 200) 45 | 46 | expect(magento_client).to receive(:post) 47 | .with('guest-carts/abc123/payment-information', { 48 | cartId: 'abc123', 49 | paymentMethod: { method: 'checkmo' }, 50 | email: 'guest@example.com' 51 | }) 52 | .and_return(response) 53 | 54 | expect(Magento::GuestCart.payment_information(attributes)).to eql('order_id') 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/magento/product_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Product do 2 | let(:magento_client) { Magento::Request.new } 3 | 4 | before { allow(Magento::Product).to receive(:request).and_return(magento_client) } 5 | 6 | describe '.find' do 7 | it 'request to /prducts/:sku' do 8 | response = double('HTTP::Response', parse: {}) 9 | 10 | expect(magento_client).to receive(:get).with('products/1243').and_return(response) 11 | 12 | Magento::Product.find('1243') 13 | end 14 | 15 | it 'returns a Magento::Product instance' do 16 | VCR.use_cassette('product/find') do 17 | product = Magento::Product.find('1243') 18 | expect(product).to be_an_instance_of(Magento::Product) 19 | end 20 | end 21 | end 22 | 23 | describe '#set_custom_attribute' do 24 | let(:product) { Magento::Product.build( 25 | sku: 25, 26 | custom_attributes: [ 27 | { attribute_code: 'description', value: 'Some description' } 28 | ] 29 | ) } 30 | 31 | context 'when the custom attribute already exists' do 32 | it 'must change the attribute value' do 33 | expect(product.description).to eql('Some description') 34 | product.set_custom_attribute(:description, 'description updated') 35 | expect(product.attr(:description)).to eql('description updated') 36 | end 37 | end 38 | 39 | context 'when the custom attribute does not exists' do 40 | it 'must add a new attribute' do 41 | expect(product.attr(:new_attribute)).to be_nil 42 | expect(product.attr(:other_new_attribute)).to be_nil 43 | 44 | product.set_custom_attribute(:new_attribute, 'value') 45 | product.set_custom_attribute('other_new_attribute', [1, 2]) 46 | 47 | expect(product.attr(:new_attribute)).to eql('value') 48 | expect(product.attr(:other_new_attribute)).to eql([1, 2]) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/magento/inventory.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Inventory 3 | class << self 4 | # 5 | # ==== Example 6 | # 7 | # Inventory.is_product_salable_for_requested_qty( 8 | # sku: '4321', 9 | # stock_id: 1, 10 | # requested_qty: 2 11 | # ) 12 | # # => 13 | # OpenStruct { 14 | # :salable => false, 15 | # :errors => [ 16 | # [0] { 17 | # "code" => "back_order-disabled", 18 | # "message" => "Backorders are disabled" 19 | # }, 20 | # ... 21 | # ] 22 | # } 23 | # 24 | # @return OpenStruct 25 | def is_product_salable_for_requested_qty(sku:, stock_id:, requested_qty:) 26 | result = Request.new.get( 27 | "inventory/is-product-salable-for-requested-qty/#{sku}/#{stock_id}/#{requested_qty}" 28 | ).parse 29 | 30 | OpenStruct.new(result) 31 | end 32 | 33 | def get_product_salable_quantity(sku:, stock_id:) 34 | Request.new.get( 35 | "inventory/get-product-salable-quantity/#{sku}/#{stock_id}" 36 | ).parse 37 | end 38 | 39 | # 40 | # ==== Example 41 | # 42 | # Inventory.update_source_items( 43 | # [ 44 | # { 45 | # "sku": "new_product1", 46 | # "source_code": "central", 47 | # "quantity": 1000, 48 | # "status": 1 49 | # }, 50 | # { 51 | # "sku": "new_product1", 52 | # "source_code": "east", 53 | # "quantity": 2000, 54 | # "status": 1 55 | # } 56 | # ] 57 | # ) 58 | # # => [] 59 | # 60 | # @return Array 61 | def update_source_items(source_items) 62 | body = { sourceItems: source_items } 63 | Request.new.post('inventory/source-items', body).parse 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/magento/model_mapper.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | module ModelMapper 3 | def self.to_hash(object) 4 | hash = {} 5 | object.instance_variables.each do |attr| 6 | key = attr.to_s.delete('@') 7 | value = object.send(key) 8 | value = to_hash(value) if value.class.name.include?('Magento::') 9 | if value.is_a? Array 10 | value = value.map do |item| 11 | item.class.name.include?('Magento::') ? to_hash(item) : item 12 | end 13 | end 14 | hash[key] = value 15 | end 16 | hash 17 | end 18 | 19 | def self.map_hash(model, values) 20 | object = model.is_a?(Class) ? model.new : model 21 | values.each do |key, value| 22 | unless object.respond_to?(key) && object.respond_to?("#{key}=") 23 | object.singleton_class.instance_eval { attr_accessor key } 24 | end 25 | 26 | if value.is_a?(Hash) 27 | class_name = Magento.inflector.camelize(Magento.inflector.singularize(key)) 28 | value = map_hash(Object.const_get("Magento::#{class_name}"), value) 29 | elsif value.is_a?(Array) 30 | value = map_array(key, value) 31 | end 32 | object.send("#{key}=", value) 33 | end 34 | object 35 | end 36 | 37 | def self.map_array(key, values) 38 | result = [] 39 | values.each do |value| 40 | if value.is_a?(Hash) 41 | class_name = Magento.inflector.camelize(Magento.inflector.singularize(key)) 42 | result << map_hash(Object.const_get("Magento::#{class_name}"), value) 43 | else 44 | result << value 45 | end 46 | end 47 | result 48 | end 49 | end 50 | 51 | module ModelParser 52 | module ClassMethods 53 | def build(attributes) 54 | ModelMapper.map_hash(self, attributes) 55 | end 56 | end 57 | 58 | def self.included(base) 59 | base.extend(ClassMethods) 60 | end 61 | 62 | def to_h 63 | ModelMapper.to_hash(self) 64 | end 65 | end 66 | end -------------------------------------------------------------------------------- /lib/magento/import/product.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module Magento 4 | module Import 5 | class Product 6 | def initialize(website_ids, images_folder = nil) 7 | @website_ids = website_ids 8 | @image_finder = images_folder ? ImageFinder.new(images_folder) : nil 9 | end 10 | 11 | def import(products) 12 | products.each do |product| 13 | params = Magento::Params::CreateProduct.new( 14 | sku: product.sku, 15 | name: product.name.gsub(/[ ]+/, ' '), 16 | description: product.description || product.name, 17 | brand: product.brand, 18 | price: product.price.to_f, 19 | special_price: product.special_price ? product.special_price.to_f : nil, 20 | quantity: numeric?(product.quantity) ? product.quantity.to_f : 0, 21 | weight: 0.3, 22 | manage_stock: numeric?(product.quantity), 23 | attribute_set_id: 4, 24 | category_ids: [product.cat1, product.cat2, product.cat3].compact, 25 | website_ids: @website_ids, 26 | images: images(product) 27 | ).to_h 28 | 29 | product = Magento::Product.create(params) 30 | 31 | puts "Created product: #{product.sku} => #{product.name}" 32 | rescue => e 33 | puts "Error on create: #{product.sku} => #{product.name}" 34 | puts " - Error details: #{e}" 35 | end 36 | end 37 | 38 | private 39 | 40 | def images(product) 41 | return [] unless product.main_image.to_s =~ URI::regexp || @image_finder 42 | 43 | image = product.main_image || @image_finder.find_by_name(product.sku) 44 | return [] unless image 45 | 46 | Magento::Params::CreateImage.new( 47 | path: image, 48 | title: product.name, 49 | position: 0, 50 | main: true 51 | ).variants 52 | end 53 | 54 | def numeric?(value) 55 | !!(value.to_s =~ /^[\d]+$/) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/vcr_cassettes/order/send_email.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: "/rest/all/V1/orders/11735/emails" 6 | body: 7 | encoding: UTF-8 8 | string: '' 9 | headers: 10 | Authorization: 11 | - Bearer 12 | Connection: 13 | - close 14 | Host: 15 | - "" 16 | User-Agent: 17 | - http.rb/4.4.1 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Thu, 28 Jan 2021 02:19:28 GMT 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Transfer-Encoding: 28 | - chunked 29 | Connection: 30 | - close 31 | Set-Cookie: 32 | - PHPSESSID=2smb8fbfrh89ov5l6dsaog9ol3; expires=Thu, 28-Jan-2021 03:19:28 GMT; 33 | Max-Age=3600; path=/; domain=; secure; HttpOnly 34 | - __cfduid=d6599c746d9235e60e31314f2c23603101611800368; expires=Sat, 27-Feb-21 35 | 02:19:28 GMT; path=/; domain=.superbomemcasa.com.br; HttpOnly; SameSite=Lax; 36 | Secure 37 | Vary: 38 | - Accept-Encoding 39 | Expires: 40 | - Thu, 19 Nov 1981 08:52:00 GMT 41 | Cache-Control: 42 | - no-store, no-cache, must-revalidate 43 | Pragma: 44 | - no-cache 45 | X-Frame-Options: 46 | - SAMEORIGIN 47 | Cf-Cache-Status: 48 | - DYNAMIC 49 | Cf-Request-Id: 50 | - 07e862ec7d0000f603b22c5000000001 51 | Expect-Ct: 52 | - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" 53 | Report-To: 54 | - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report?s=LcpIGCN14P04w%2B12%2BJip%2BoqrTEEJHVD5vP3%2FqWTDV843xCQbwKwwisiq23jbvZ0fOkDmTOxTTgA804Jq1CCnW9d3I14x%2BUGjfoosFZuRXzpxNONKefOyryVm4KckyBHTJSF1EtaW"}]}' 55 | Nel: 56 | - '{"max_age":604800,"report_to":"cf-nel"}' 57 | Server: 58 | - cloudflare 59 | Cf-Ray: 60 | - 61873a8d9b69f603-GRU 61 | body: 62 | encoding: UTF-8 63 | string: 'false' 64 | recorded_at: Thu, 28 Jan 2021 02:19:28 GMT 65 | recorded_with: VCR 6.0.0 66 | -------------------------------------------------------------------------------- /spec/magento/core/model_mapper_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::ModelMapper do 2 | describe '.to_hash' do 3 | it 'serializes object to hash' do 4 | class Magento::SameClass 5 | attr_accessor :name, :description, :items 6 | end 7 | 8 | object = Magento::SameClass.new 9 | object.name = 'Some name' 10 | object.description = 'Some description' 11 | object.items = [object.dup] 12 | 13 | expect(Magento::ModelMapper.to_hash(object)).to eql({ 14 | 'name' => 'Some name', 15 | 'description' => 'Some description', 16 | 'items' => [{ 'name' => 'Some name', 'description' => 'Some description' }] 17 | }) 18 | end 19 | end 20 | 21 | describe '.map_hash' do 22 | it 'returns magento object from hash' do 23 | class Magento::SameClass; end 24 | hash = { name: 'Some name', price: 10.99 } 25 | 26 | object = Magento::ModelMapper.map_hash(Magento::SameClass, hash) 27 | 28 | expect(object).to be_instance_of(Magento::SameClass) 29 | expect(object.name).to eql hash[:name] 30 | expect(object.price).to eql hash[:price] 31 | end 32 | end 33 | 34 | describe '.map_array' do 35 | it 'returns magento object list from array of hash' do 36 | class Magento::SameClass; end 37 | array = [{ name: 'Some name', price: 10.99 }] 38 | 39 | object = Magento::ModelMapper.map_array('same_class', array) 40 | 41 | expect(object).to be_a(Array) 42 | expect(object).to all be_instance_of(Magento::SameClass) 43 | end 44 | end 45 | 46 | describe 'include ModelParser' do 47 | before do 48 | class Magento::SameClass 49 | include Magento::ModelParser 50 | end 51 | end 52 | 53 | let(:hash) { { name: 'Same name' } } 54 | 55 | describe '.build' do 56 | it 'calls Magento::ModelMapper.map_hash' do 57 | expect(Magento::ModelMapper).to receive(:map_hash) 58 | .with(Magento::SameClass, hash) 59 | 60 | Magento::SameClass.build(hash) 61 | end 62 | end 63 | 64 | describe '#to_h' do 65 | it 'calls Magento::ModelMapper.to_hash' do 66 | object = Magento::SameClass.build(hash) 67 | 68 | expect(Magento::ModelMapper).to receive(:to_hash).with(object) 69 | 70 | object.to_h 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/magento/customer.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Customer < Model 3 | self.endpoint = 'customers/search' 4 | 5 | def fullname 6 | "#{@firstname} #{@lastname}" 7 | end 8 | 9 | def update(attributes) 10 | raise "id not present" if @id.nil? 11 | 12 | attributes.each { |key, value| send("#{key}=", value) } 13 | save 14 | end 15 | 16 | class << self 17 | alias_method :find_by_id, :find 18 | 19 | def update(id, attributes) 20 | hash = request.put("customers/#{id}", { customer: attributes }).parse 21 | 22 | block_given? ? yield(hash) : build(hash) 23 | end 24 | 25 | def create(attributes) 26 | attributes.transform_keys!(&:to_sym) 27 | password = attributes.delete :password 28 | hash = request.post("customers", { 29 | customer: attributes, 30 | password: password 31 | }).parse 32 | build(hash) 33 | end 34 | 35 | def find_by_token(token) 36 | user_request = Request.new(config: Magento.configuration.copy_with(token: token)) 37 | customer_hash = user_request.get('customers/me').parse 38 | build(customer_hash) 39 | end 40 | 41 | def find(id) 42 | hash = request.get("customers/#{id}").parse 43 | build(hash) 44 | end 45 | 46 | # 47 | # Log in to a user account 48 | # 49 | # Example: 50 | # Magento::Customer.login('customer@gmail.com', '123456') 51 | # 52 | # @return String: return the user token 53 | def login(username, password) 54 | request.post("integration/customer/token", { 55 | username: username, 56 | password: password 57 | }) 58 | end 59 | 60 | # 61 | # Reset a user's password 62 | # 63 | # Example: 64 | # Magento::Customer.reset_password( 65 | # email: 'customer@gmail.com', 66 | # reset_token: 'mEKMTciuhPfWkQ3zHTCLIJNC', 67 | # new_password: '123456' 68 | # ) 69 | # 70 | # @return Bolean: true on success, raise exception otherwise 71 | def reset_password(email:, reset_token:, new_password:) 72 | request.post("customers/resetPassword", { 73 | email: email, 74 | reset_token: reset_token, 75 | new_password: new_password 76 | }).parse 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/magento/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Magento 6 | class Model 7 | include Magento::ModelParser 8 | 9 | def save 10 | self.class.update(send(self.class.primary_key), to_h) do |hash| 11 | update_attributes(hash) 12 | end 13 | end 14 | 15 | def update(attrs) 16 | raise "#{self.class.name} not saved" if send(self.class.primary_key).nil? 17 | 18 | self.class.update(send(self.class.primary_key), attrs) do |hash| 19 | update_attributes(hash) 20 | end 21 | end 22 | 23 | def delete 24 | self.class.delete(send(self.class.primary_key)) 25 | end 26 | 27 | def id 28 | @id || send(self.class.primary_key) 29 | end 30 | 31 | protected def update_attributes(hash) 32 | ModelMapper.map_hash(self, hash) 33 | end 34 | 35 | class << self 36 | extend Forwardable 37 | 38 | def_delegators :query, :all, :find_each, :page, :per, :page_size, :order, :select, 39 | :where, :first, :find_by, :count 40 | 41 | def find(id) 42 | hash = request.get("#{api_resource}/#{id}").parse 43 | build(hash) 44 | end 45 | 46 | def create(attributes) 47 | body = { entity_key => attributes } 48 | hash = request.post(api_resource, body).parse 49 | build(hash) 50 | end 51 | 52 | def delete(id) 53 | request.delete("#{api_resource}/#{id}").status.success? 54 | end 55 | 56 | def update(id, attributes) 57 | body = { entity_key => attributes } 58 | hash = request.put("#{api_resource}/#{id}", body).parse 59 | 60 | block_given? ? yield(hash) : build(hash) 61 | end 62 | 63 | def api_resource 64 | endpoint || Magento.inflector.pluralize(entity_name) 65 | end 66 | 67 | def entity_name 68 | Magento.inflector.underscore(name).sub('magento/', '') 69 | end 70 | 71 | def primary_key 72 | @primary_key || :id 73 | end 74 | 75 | protected 76 | 77 | attr_writer :primary_key 78 | attr_accessor :endpoint, :entity_key 79 | 80 | def entity_key 81 | @entity_key || entity_name 82 | end 83 | 84 | def query 85 | Query.new(self) 86 | end 87 | 88 | def request 89 | Request.new 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/magento/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | require 'http' 5 | 6 | module Magento 7 | class Request 8 | attr_reader :config 9 | 10 | def initialize(config: Magento.configuration) 11 | @config = config 12 | end 13 | 14 | def get(resource) 15 | save_request(:get, url(resource)) 16 | handle_error http_auth.get(url(resource)) 17 | end 18 | 19 | def put(resource, body) 20 | save_request(:put, url(resource), body) 21 | handle_error http_auth.put(url(resource), json: body) 22 | end 23 | 24 | def post(resource, body = nil, url_completa = false) 25 | url = url_completa ? resource : url(resource) 26 | save_request(:post, url, body) 27 | handle_error http_auth.post(url, json: body) 28 | end 29 | 30 | def delete(resource) 31 | save_request(:delete, url(resource)) 32 | handle_error http_auth.delete(url(resource)) 33 | end 34 | 35 | private 36 | 37 | def http_auth 38 | HTTP.auth("Bearer #{config.token}") 39 | .timeout(connect: config.timeout, read: config.open_timeout) 40 | end 41 | 42 | def base_url 43 | url = config.url.to_s.sub(%r{/$}, '') 44 | "#{url}/rest/#{config.store}/V1" 45 | end 46 | 47 | def url(resource) 48 | "#{base_url}/#{resource}" 49 | end 50 | 51 | def handle_error(resp) 52 | return resp if resp.status.success? 53 | 54 | begin 55 | msg = resp.parse['message'] 56 | errors = resp.parse['errors'] || resp.parse['parameters'] 57 | case errors 58 | when Hash 59 | errors.each { |k, v| msg.sub! "%#{k}", v } 60 | when Array 61 | errors.each_with_index { |v, i| msg.sub! "%#{i + 1}", v.to_s } 62 | end 63 | rescue StandardError 64 | msg = 'Failed access to the magento server' 65 | errors = [] 66 | end 67 | 68 | raise Magento::NotFound.new(msg, resp.status.code, errors, @request) if resp.status.not_found? 69 | 70 | raise Magento::MagentoError.new(msg, resp.status.code, errors, @request) 71 | end 72 | 73 | def save_request(method, url, body = nil) 74 | begin 75 | body = body.symbolize_keys[:product].reject { |e| e == :media_gallery_entries } 76 | rescue StandardError 77 | end 78 | 79 | @request = { method: method, url: url, body: body } 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/magento.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | require 'dry/inflector' 5 | require 'active_support/core_ext/string/inflections' 6 | require 'active_support/core_ext/hash/keys' 7 | require 'active_support/core_ext/module/delegation' 8 | 9 | require_relative 'magento/configuration' 10 | require_relative 'magento/errors' 11 | require_relative 'magento/request' 12 | require_relative 'magento/model_mapper' 13 | require_relative 'magento/params' 14 | require_relative 'magento/polymorphic_model' 15 | require_relative 'magento/model' 16 | require_relative 'magento/record_collection' 17 | require_relative 'magento/query' 18 | require_relative 'magento/category' 19 | require_relative 'magento/product' 20 | require_relative 'magento/country' 21 | require_relative 'magento/customer' 22 | require_relative 'magento/order' 23 | require_relative 'magento/invoice' 24 | require_relative 'magento/guest_cart' 25 | require_relative 'magento/sales_rule' 26 | require_relative 'magento/inventory' 27 | require_relative 'magento/import' 28 | require_relative 'magento/cart' 29 | require_relative 'magento/tax_rule' 30 | require_relative 'magento/tax_rate' 31 | 32 | require_relative 'magento/params/create_custom_attribute' 33 | require_relative 'magento/params/create_image' 34 | require_relative 'magento/params/create_category' 35 | require_relative 'magento/params/create_product' 36 | require_relative 'magento/params/create_product_link' 37 | 38 | Dir[File.expand_path('magento/shared/*.rb', __dir__)].map { |f| require f } 39 | 40 | module Magento 41 | class << self 42 | attr_writer :configuration 43 | 44 | delegate :url=, :token=, :store=, :open_timeout=, :timeout=, to: :configuration 45 | 46 | def inflector 47 | @inflector ||= Dry::Inflector.new do |inflections| 48 | inflections.singular 'children_data', 'category' 49 | inflections.singular 'item_applied_taxes', 'item_applied_tax' 50 | inflections.singular 'applied_taxes', 'applied_tax' 51 | end 52 | end 53 | end 54 | 55 | def self.configuration 56 | @configuration ||= Configuration.new 57 | end 58 | 59 | def self.reset 60 | @configuration = Configuration.new 61 | end 62 | 63 | def self.configure 64 | yield(configuration) 65 | end 66 | 67 | def self.with_config(params) 68 | @old_configuration = configuration 69 | self.configuration = configuration.copy_with(**params) 70 | yield 71 | ensure 72 | @configuration = @old_configuration 73 | end 74 | 75 | def self.production? 76 | ENV['RACK_ENV'] == 'production' || 77 | ENV['RAILS_ENV'] == 'production' || 78 | ENV['PRODUCTION'] || 79 | ENV['production'] 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/magento/invoice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class Invoice < Model 5 | self.primary_key = :entity_id 6 | self.entity_key = :entity 7 | 8 | # 9 | # Sets invoice capture. 10 | def capture 11 | self.class.capture(id) 12 | end 13 | 14 | # 15 | # Voids a specified invoice. 16 | # 17 | # @return {Boolean} 18 | def void 19 | self.class.void(id) 20 | end 21 | 22 | # 23 | # Emails a user a specified invoice. 24 | # 25 | # @return {Boolean} 26 | def send_email 27 | self.class.send_email(id) 28 | end 29 | 30 | # 31 | # Create refund for invoice 32 | # 33 | # invoice = Magento::Invoice.find(invoice_id) 34 | # 35 | # invoice.refund # or you can pass parameters 36 | # invoice.refund(isOnline: true) # See the refund class method for more information 37 | # 38 | # @return {Integer} return the refund id 39 | def refund(refund_params = nil) 40 | self.class.refund(id, refund_params) 41 | end 42 | 43 | class << self 44 | def save 45 | raise NotImplementedError 46 | end 47 | 48 | def update(_attributes) 49 | raise NotImplementedError 50 | end 51 | 52 | def create(_attributes) 53 | raise NotImplementedError 54 | end 55 | 56 | # 57 | # Sets invoice capture. 58 | def capture(invoice_id) 59 | request.post("invoices/#{invoice_id}/capture").parse 60 | end 61 | 62 | # 63 | # Voids a specified invoice. 64 | # 65 | # @return {Boolean} 66 | def void(invoice_id) 67 | request.post("invoices/#{invoice_id}/void").parse 68 | end 69 | 70 | # 71 | # Emails a user a specified invoice. 72 | # 73 | # @return {Boolean} 74 | def send_email(invoice_id) 75 | request.post("invoices/#{invoice_id}/emails").parse 76 | end 77 | 78 | # 79 | # Lists comments for a specified invoice. 80 | # 81 | # Magento::Invoice.comments(invoice_id).all 82 | # Magento::Invoice.comments(invoice_id).where(created_at_gt: Date.today.prev_day).all 83 | def comments(invoice_id) 84 | api_resource = "invoices/#{invoice_id}/comments" 85 | Query.new(PolymorphicModel.new(Comment, api_resource)) 86 | end 87 | 88 | # 89 | # Create refund for invoice 90 | # 91 | # Magento::Invoice.refund(invoice_id) 92 | # 93 | # or 94 | # 95 | # Magento::Invoice.refund( 96 | # invoice_id, 97 | # items: [ 98 | # { 99 | # extension_attributes: {}, 100 | # order_item_id: 0, 101 | # qty: 0 102 | # } 103 | # ], 104 | # isOnline: true, 105 | # notify: true, 106 | # appendComment: true, 107 | # comment: { 108 | # extension_attributes: {}, 109 | # comment: string, 110 | # is_visible_on_front: 0 111 | # }, 112 | # arguments: { 113 | # shipping_amount: 0, 114 | # adjustment_positive: 0, 115 | # adjustment_negative: 0, 116 | # extension_attributes: { 117 | # return_to_stock_items: [ 118 | # 0 119 | # ] 120 | # } 121 | # } 122 | # ) 123 | # 124 | # to complete [documentation](https://magento.redoc.ly/2.4-admin/tag/invoicescomments#operation/salesRefundInvoiceV1ExecutePost) 125 | # 126 | # @return {Integer} return the refund id 127 | def refund(invoice_id, refund_params=nil) 128 | request.post("invoice/#{invoice_id}/refund", refund_params).parse 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/magento/core/record_collection_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::RecordCollection do 2 | subject { Magento::RecordCollection.new(items: []) } 3 | 4 | describe 'read only attributes' do 5 | it { is_expected.to respond_to(:items) } 6 | it { is_expected.to respond_to(:search_criteria) } 7 | it { is_expected.to respond_to(:total_count) } 8 | it { is_expected.not_to respond_to(:items=) } 9 | it { is_expected.not_to respond_to(:search_criteria=) } 10 | it { is_expected.not_to respond_to(:total_count=) } 11 | end 12 | 13 | describe '#last_page?' do 14 | it do 15 | subject = create_subject_with_pagination(total_count: 60, current_page: 6, page_size: 10) 16 | expect(subject.last_page?).to be true 17 | end 18 | 19 | it do 20 | subject = create_subject_with_pagination(total_count: 60, current_page: 5, page_size: 10) 21 | expect(subject.last_page?).to be false 22 | end 23 | end 24 | 25 | describe '#next_page' do 26 | it 'returns next page number' do 27 | subject = create_subject_with_pagination(current_page: 5, total_count: 60, page_size: 10) 28 | expect(subject.next_page).to be 6 29 | end 30 | 31 | it 'returns nil when current page is the last' do 32 | subject = create_subject_with_pagination(current_page: 6, total_count: 60, page_size: 10) 33 | expect(subject.next_page).to be nil 34 | end 35 | end 36 | 37 | describe '.from_magento_response' do 38 | let(:response) do 39 | { 40 | 'items' => [ 41 | { 'id' => 1, 'name' => 'Product one' }, 42 | { 'id' => 2, 'name' => 'Product two' } 43 | ], 44 | 'total_count' => 12, 45 | 'search_criteria' => { 'current_page' => 2, 'page_size' => 10 } 46 | } 47 | end 48 | 49 | it 'create RecordCollection instance from magento response' do 50 | records = Magento::RecordCollection.from_magento_response(response, model: Magento::Product) 51 | 52 | expect(records).to all be_a_instance_of(Magento::Product) 53 | expect(records.size).to eql(2) 54 | expect(records.total_count).to eql(12) 55 | end 56 | 57 | it 'allows specify the iterable field' do 58 | response['data'] = response.delete 'items' 59 | 60 | records = Magento::RecordCollection.from_magento_response( 61 | response, 62 | model: Magento::Product, 63 | iterable_field: 'data' 64 | ) 65 | 66 | expect(records.size).to eql(2) 67 | expect(records).to all be_a_instance_of(Magento::Product) 68 | end 69 | end 70 | 71 | describe 'delegated methods' do 72 | let(:methods) do 73 | %i[ 74 | count 75 | length 76 | size 77 | first 78 | last 79 | [] 80 | find 81 | each 82 | each_with_index 83 | sample 84 | map 85 | select 86 | filter 87 | reject 88 | collect 89 | take 90 | take_while 91 | sort 92 | sort_by 93 | reverse_each 94 | reverse 95 | all? 96 | any? 97 | none? 98 | one? 99 | empty? 100 | ] 101 | end 102 | 103 | it 'from #items' do 104 | methods.each do |method| 105 | expect(subject).to respond_to(method) 106 | expect(subject.items).to receive(method) 107 | subject.send(method) 108 | end 109 | end 110 | end 111 | 112 | def create_subject_with_pagination(total_count:, current_page:, page_size:) 113 | search_criteria = double(:search_criteria, current_page: current_page, page_size: page_size) 114 | Magento::RecordCollection.new(items: [], total_count: total_count, search_criteria: search_criteria) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/magento/params/create_image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open-uri' 4 | require 'mini_magick' 5 | 6 | module Magento 7 | module Params 8 | 9 | # Helper class to create product image params. 10 | # before generating the hash, the following image treatments are performed: 11 | # - resize image 12 | # - remove alpha 13 | # - leaves square 14 | # - convert image to jpg 15 | # 16 | # Example: 17 | # 18 | # params = Magento::Params::CreateImage.new( 19 | # title: 'Image title', 20 | # path: '/path/to/image.jpg', # or url 21 | # position: 1, 22 | # size: 'small', # options: 'large'(defaut), 'medium' and 'small', 23 | # disabled: true, # default is false, 24 | # main: true, # default is false, 25 | # ).to_h 26 | # 27 | # Magento::Product.add_media('sku', params) 28 | # 29 | # The resize defaut confiruration is: 30 | # 31 | # Magento.configure do |config| 32 | # config.product_image.small_size = '200x200>' 33 | # config.product_image.medium_size = '400x400>' 34 | # config.product_image.large_size = '800x800>' 35 | # end 36 | # 37 | class CreateImage < Dry::Struct 38 | VARIANTS = { 39 | 'large' => :image, 40 | 'medium' => :small_image, 41 | 'small' => :thumbnail 42 | }.freeze 43 | 44 | attribute :title, Type::String 45 | attribute :path, Type::String 46 | attribute :position, Type::Integer 47 | attribute :size, Type::String.default('large').enum(*VARIANTS.keys) 48 | attribute :disabled, Type::Bool.default(false) 49 | attribute :main, Type::Bool.default(false) 50 | 51 | def to_h 52 | { 53 | "disabled": disabled, 54 | "media_type": 'image', 55 | "label": title, 56 | "position": position, 57 | "content": { 58 | "base64_encoded_data": base64, 59 | "type": mini_type, 60 | "name": filename 61 | }, 62 | "types": main ? [VARIANTS[size]] : [] 63 | } 64 | end 65 | 66 | # Generates a list containing an Magento::Params::CreateImage 67 | # instance for each size of the same image. 68 | # 69 | # Example: 70 | # 71 | # params = Magento::Params::CreateImage.new( 72 | # title: 'Image title', 73 | # path: '/path/to/image.jpg', # or url 74 | # position: 1, 75 | # ).variants 76 | # 77 | # params.map(&:size) 78 | # => ['large', 'medium', 'small'] 79 | # 80 | def variants 81 | VARIANTS.keys.map do |size| 82 | CreateImage.new(attributes.merge(size: size, disabled: size != 'large')) 83 | end 84 | end 85 | 86 | private 87 | 88 | def base64 89 | Base64.strict_encode64(File.open(file.path).read).to_s 90 | end 91 | 92 | def file 93 | @file ||= MiniMagick::Image.open(path).tap do |b| 94 | b.resize(Magento.configuration.product_image.send(size + '_size')) 95 | bigger_side = b.dimensions.max 96 | b.combine_options do |c| 97 | c.background '#FFFFFF' 98 | c.alpha 'remove' 99 | c.gravity 'center' 100 | c.extent "#{bigger_side}x#{bigger_side}" 101 | c.strip 102 | end 103 | b.format 'jpg' 104 | end 105 | rescue => e 106 | raise "Error on read image #{path}: #{e}" 107 | end 108 | 109 | def filename 110 | "#{title.parameterize}-#{VARIANTS[size]}.jpg" 111 | end 112 | 113 | def mini_type 114 | file.mime_type 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/vcr_cassettes/product/find.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: "/rest/all/V1/products/1243" 6 | body: 7 | encoding: UTF-8 8 | string: '' 9 | headers: 10 | Authorization: 11 | - Bearer 12 | Connection: 13 | - close 14 | Host: 15 | - "" 16 | User-Agent: 17 | - http.rb/4.4.1 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Tue, 26 Jan 2021 12:14:13 GMT 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Transfer-Encoding: 28 | - chunked 29 | Connection: 30 | - close 31 | Set-Cookie: 32 | - PHPSESSID=ja838407a6que0872jcavh6nnn; expires=Tue, 26-Jan-2021 13:14:13 GMT; 33 | Max-Age=3600; path=/; domain=; secure; HttpOnly 34 | - __cfduid=d55e21b42ee59161b6b98cd672c3aed2e1611663253; expires=Thu, 25-Feb-21 35 | 12:14:13 GMT; path=/; domain=.superbomemcasa.com.br; HttpOnly; SameSite=Lax; 36 | Secure 37 | Vary: 38 | - Accept-Encoding 39 | Expires: 40 | - Thu, 19 Nov 1981 08:52:00 GMT 41 | Cache-Control: 42 | - no-store, no-cache, must-revalidate 43 | Pragma: 44 | - no-cache 45 | X-Frame-Options: 46 | - SAMEORIGIN 47 | Cf-Cache-Status: 48 | - DYNAMIC 49 | Cf-Request-Id: 50 | - 07e036b79f0000f633d7210000000001 51 | Expect-Ct: 52 | - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" 53 | Report-To: 54 | - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report?s=ojIkem%2BboVf6LDGgK2JdO7t9VzPZ4pDxSmPkBKJMXK%2FTZPZQJMXgM4dLSxKcYOfxbXUWorkR0un78o%2BvbskH1pvLHaYY1ynWz9nSQNNSLAltK%2BPoysRl0aKNwHrsnpZPJ%2FETaTj3"}],"max_age":604800,"group":"cf-nel"}' 55 | Nel: 56 | - '{"max_age":604800,"report_to":"cf-nel"}' 57 | Server: 58 | - cloudflare 59 | Cf-Ray: 60 | - 617a2705cd1ff633-GRU 61 | body: 62 | encoding: UTF-8 63 | string: '{"id":2455,"sku":"1243","name":"Ketchup Hellmann''s Tradicional 380 64 | G ","attribute_set_id":4,"price":9.09,"status":1,"visibility":4,"type_id":"simple","created_at":"2020-03-28 65 | 23:53:11","updated_at":"2020-08-29 00:22:59","weight":0.3,"extension_attributes":{"website_ids":[1,2],"category_links":[{"position":0,"category_id":"2451"},{"position":0,"category_id":"2452"}],"stock_item":{"item_id":2452,"product_id":2455,"stock_id":1,"qty":30,"is_in_stock":true,"is_qty_decimal":false,"show_default_notification_message":false,"use_config_min_qty":true,"min_qty":0,"use_config_min_sale_qty":0,"min_sale_qty":1,"use_config_max_sale_qty":true,"max_sale_qty":10000,"use_config_backorders":true,"backorders":0,"use_config_notify_stock_qty":true,"notify_stock_qty":1,"use_config_qty_increments":true,"qty_increments":0,"use_config_enable_qty_inc":true,"enable_qty_increments":false,"use_config_manage_stock":true,"manage_stock":true,"low_stock_date":null,"is_decimal_divided":false,"stock_status_changed_auto":0}},"product_links":[],"options":[],"media_gallery_entries":[{"id":7913,"media_type":"image","label":"KETCHUP 66 | HELLMANN''S 380G TRADICIONAL","position":0,"disabled":false,"types":["image","small_image","thumbnail","swatch_image"],"file":"\/7\/8\/7891150027848_1_1_1200_72_RGB_1.png"}],"tier_prices":[],"custom_attributes":[{"attribute_code":"description","value":"Ketchup 67 | Tradicional Hellmann''s Squeeze 380g"},{"attribute_code":"image","value":"\/7\/8\/7891150027848_1_1_1200_72_RGB_1.png"},{"attribute_code":"url_key","value":"ketchup-hellmann-s-380g-tradicional"},{"attribute_code":"gift_message_available","value":"0"},{"attribute_code":"mostrar","value":"0"},{"attribute_code":"small_image","value":"\/7\/8\/7891150027848_1_1_1200_72_RGB_1.png"},{"attribute_code":"options_container","value":"container2"},{"attribute_code":"thumbnail","value":"\/7\/8\/7891150027848_1_1_1200_72_RGB_1.png"},{"attribute_code":"swatch_image","value":"\/7\/8\/7891150027848_1_1_1200_72_RGB_1.png"},{"attribute_code":"msrp_display_actual_price_type","value":"0"},{"attribute_code":"tax_class_id","value":"2"},{"attribute_code":"timershow","value":"1"},{"attribute_code":"gtin","value":"7891150027848,97623"},{"attribute_code":"required_options","value":"0"},{"attribute_code":"has_options","value":"0"},{"attribute_code":"image_label","value":"KETCHUP 68 | HELLMANN''S 380G TRADICIONAL"},{"attribute_code":"category_ids","value":["2451","2452"]},{"attribute_code":"small_image_label","value":"KETCHUP 69 | HELLMANN''S 380G TRADICIONAL"},{"attribute_code":"bebida_alcoolica","value":"0"},{"attribute_code":"thumbnail_label","value":"KETCHUP 70 | HELLMANN''S 380G TRADICIONAL"},{"attribute_code":"featured","value":"1"}]}' 71 | recorded_at: Tue, 26 Jan 2021 12:14:13 GMT 72 | recorded_with: VCR 6.0.0 73 | -------------------------------------------------------------------------------- /lib/magento/guest_cart.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class GuestCart < Model 5 | self.endpoint = 'guest-carts' 6 | self.primary_key = :cart_id 7 | 8 | def add_item(item_attributes) 9 | attributes = { cartItem: item_attributes.merge(quote_id: cart_id) } 10 | item = self.class.add_item(cart_id, attributes) 11 | @items = @items.reject {|i| i.item_id == item.item_id} << item if @items 12 | item 13 | end 14 | 15 | def delete_item(item_id, load_cart_info: false) 16 | self.class.delete_item(cart_id, item_id, load_cart_info: load_cart_info) 17 | end 18 | 19 | def delete_items(load_cart_info: false) 20 | self.class.delete_items(cart_id, load_cart_info: load_cart_info) 21 | end 22 | 23 | def get_items 24 | self.class.get_items(cart_id) 25 | end 26 | 27 | # 28 | # Set payment information to finish the order 29 | # 30 | # Example: 31 | # cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 32 | # 33 | # # or use "build" to not request information from the magento API 34 | # cart = Magento::GuestCart.build({ 'cart_id' => 'aj8oUtY1Qi44Fror6UWVN7ftX1idbBKN' }) 35 | # 36 | # cart.payment_information( 37 | # email: 'customer@gmail.com', 38 | # payment: { method: 'cashondelivery' } 39 | # ) 40 | # 41 | # @return String: return the order id 42 | def payment_information(email:, payment:) 43 | attributes = { cartId: cart_id, paymentMethod: payment, email: email } 44 | self.class.payment_information(attributes) 45 | end 46 | 47 | # 48 | # Add a coupon by code to the current cart. 49 | # 50 | # Example 51 | # cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 52 | # cart.add_coupon('COAU4HXE0I') 53 | # 54 | # @return Boolean: true on success, false otherwise 55 | def add_coupon(coupon) 56 | self.class.add_coupon(cart_id, coupon) 57 | end 58 | 59 | # Delete cart's coupon 60 | # 61 | # Example: 62 | # cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 63 | # cart.delete_coupon() 64 | # 65 | # @return Boolean: true on success, raise exception otherwise 66 | def delete_coupon 67 | self.class.delete_coupon(cart_id) 68 | end 69 | 70 | class << self 71 | def create(load_cart_info: false) 72 | cart = build(cart_id: request.post(api_resource).parse) 73 | find cart.id if load_cart_info 74 | end 75 | 76 | def find(id) 77 | build request.get("#{api_resource}/#{id}").parse.merge(cart_id: id) 78 | end 79 | 80 | # 81 | # Set payment information to finish the order using class method 82 | # 83 | # Example: 84 | # Magento::GuestCart.payment_information( 85 | # cartId: 'aj8oUtY1Qi44Fror6UWVN7ftX1idbBKN', 86 | # paymentMethod: { method: 'cashondelivery' }, 87 | # email: email 88 | # ) 89 | # 90 | # @return String: return the order id 91 | def payment_information(attributes) 92 | attributes = attributes.transform_keys(&:to_sym) 93 | url = "#{api_resource}/#{attributes[:cartId]}/payment-information" 94 | request.post(url, attributes).parse 95 | end 96 | 97 | def add_item(id, attributes) 98 | url = "#{api_resource}/#{id}/items" 99 | hash = request.post(url, attributes).parse 100 | Magento::ModelMapper.map_hash(Magento::Item, hash) 101 | end 102 | 103 | def delete_item(id, item_id, load_cart_info: false) 104 | url = "#{api_resource}/#{id}/items/#{item_id}" 105 | request.delete(url).parse 106 | 107 | find(id) if load_cart_info 108 | end 109 | 110 | def delete_items(id, load_cart_info: false) 111 | items = get_items(id) 112 | 113 | items.each do |item| 114 | delete_item(id, item.item_id) 115 | end 116 | 117 | find(id) if load_cart_info 118 | end 119 | 120 | def get_items(id) 121 | url = "#{api_resource}/#{id}/items" 122 | hash = request.get(url).parse 123 | Magento::ModelMapper.map_array('Item', hash) 124 | end 125 | 126 | # 127 | # Add a coupon by code to a specified cart. 128 | # 129 | # Example 130 | # Magento::GuestCart.add_coupon( 131 | # 'aj8oUtY1Qi44Fror6UWVN7ftX1idbBKN', 132 | # 'COAU4HXE0I' 133 | # ) 134 | # 135 | # @return Boolean: true on success, false otherwise 136 | def add_coupon(id, coupon) 137 | url = "#{api_resource}/#{id}/coupons/#{coupon}" 138 | request.put(url, nil).parse 139 | end 140 | 141 | # 142 | # Delete a coupon from a specified cart. 143 | # 144 | # Example: 145 | # Magento::GuestCart.delete_coupon('aj8oUtY1Qi44Fror6UWVN7ftX1idbBKN') 146 | # 147 | # @return Boolean: true on success, raise exception otherwise 148 | def delete_coupon(id) 149 | url = "#{api_resource}/#{id}/coupons" 150 | request.delete(url).parse 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/magento/core/request_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Request do 2 | let(:config) do 3 | double('Magento::Configuration', 4 | url: 'https://site.com.br', 5 | token: 'magento-token', 6 | store: 'magento-store', 7 | timeout: 30, 8 | open_timeout: 5 9 | ) 10 | end 11 | 12 | subject { Magento::Request.new(config: config) } 13 | 14 | let(:response) do 15 | double('Response', parse: {}, status: double(:status, success?: true)) 16 | end 17 | 18 | describe '#get' do 19 | it 'calls HTTP.get with url' do 20 | expect_any_instance_of(HTTP::Client).to receive(:get) 21 | .with('https://site.com.br/rest/magento-store/V1/products') 22 | .and_return(response) 23 | 24 | subject.get('products') 25 | end 26 | end 27 | 28 | describe '#put' do 29 | it 'calls HTTP.put with url and body' do 30 | body = { product: { price: 22.50 } } 31 | 32 | expect_any_instance_of(HTTP::Client).to receive(:put) 33 | .with('https://site.com.br/rest/magento-store/V1/products', { json: body }) 34 | .and_return(response) 35 | 36 | subject.put('products', body) 37 | end 38 | end 39 | 40 | describe '#post' do 41 | it 'calls HTTP.post with url and body' do 42 | body = { product: { name: 'Some name', price: 22.50 } } 43 | 44 | expect_any_instance_of(HTTP::Client).to receive(:post) 45 | .with('https://site.com.br/rest/magento-store/V1/products', { json: body }) 46 | .and_return(response) 47 | 48 | subject.post('products', body) 49 | end 50 | 51 | it 'calls HTTP.post with the full url when url_completa is true' do 52 | body = { product: { name: 'Some name', price: 22.50 } } 53 | 54 | expect_any_instance_of(HTTP::Client).to receive(:post) 55 | .with('https://full.url', { json: body }) 56 | .and_return(response) 57 | 58 | subject.post('https://full.url', body, true) 59 | end 60 | end 61 | 62 | describe '#delete' do 63 | it 'calls HTTP.selete with url' do 64 | expect_any_instance_of(HTTP::Client).to receive(:delete) 65 | .with('https://site.com.br/rest/magento-store/V1/products/22') 66 | .and_return(response) 67 | 68 | subject.delete('products/22') 69 | end 70 | end 71 | 72 | context 'private method' do 73 | describe '#http_auth' do 74 | it 'calls HTTP.auth with token and returns HTTP::Client' do 75 | expect(HTTP).to receive(:auth).with("Bearer #{config.token}").and_return(HTTP) 76 | result = subject.send(:http_auth) 77 | expect(result).to be_a(HTTP::Client) 78 | end 79 | end 80 | 81 | describe '#base_url' do 82 | it do 83 | base_url = "https://site.com.br/rest/magento-store/V1" 84 | expect(subject.send(:base_url)).to eql(base_url) 85 | end 86 | end 87 | 88 | describe '#url' do 89 | it 'returns base_url + resource' do 90 | url = "https://site.com.br/rest/magento-store/V1/products" 91 | expect(subject.send(:url, 'products')).to eql(url) 92 | end 93 | end 94 | 95 | describe '#handle_error' do 96 | context 'when success' do 97 | it 'does nothing' do 98 | subject.send(:handle_error, response) 99 | end 100 | end 101 | 102 | context 'when status not found' do 103 | it 'reises Magento::NotFound error' do 104 | allow(response).to receive(:status).and_return( 105 | double(:status, success?: false, not_found?: true, code: 404) 106 | ) 107 | 108 | expect { subject.send(:handle_error, response) }.to raise_error(Magento::NotFound) 109 | end 110 | end 111 | 112 | context 'when other status' do 113 | it 'reises Magento::MagentoError' do 114 | allow(response).to receive(:status).and_return( 115 | double(:status, success?: false, not_found?: false, code: 422) 116 | ) 117 | 118 | expect { subject.send(:handle_error, response) }.to raise_error(Magento::MagentoError) 119 | end 120 | end 121 | end 122 | 123 | describe '#save_request' do 124 | it 'save on instance variable' do 125 | body = { quantity: 200, category_ids: [1,3,4] } 126 | 127 | subject.send(:save_request, :post, 'https:someurl.com.br', body) 128 | 129 | expect(subject.instance_variable_get(:@request)).to eql({ 130 | body: body, 131 | method: :post, 132 | url: 'https:someurl.com.br', 133 | }) 134 | end 135 | 136 | context 'when body has media_gallery_entries' do 137 | it 'removes media_gallery_entries attribute from body' do 138 | body = { product: { name: 'Name', price: 99.90, media_gallery_entries: {} } } 139 | 140 | subject.send(:save_request, :post, 'https:someurl.com.br', body) 141 | 142 | expect(subject.instance_variable_get(:@request)).to eql({ 143 | body: { name: 'Name', price: 99.90 }, 144 | method: :post, 145 | url: 'https:someurl.com.br', 146 | }) 147 | end 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/magento/core/model_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Magento::Model do 2 | class TestModel < Magento::Model 3 | attr_accessor :id, :name 4 | end 5 | 6 | let(:magento_client) { instance_double(Magento::Request) } 7 | 8 | before { allow(TestModel).to receive(:request).and_return(magento_client) } 9 | 10 | describe 'public method' do 11 | describe '.find' do 12 | it 'requests record and builds model' do 13 | response = double('Response', parse: { id: 1 }) 14 | expect(magento_client).to receive(:get).with('test_models/1').and_return(response) 15 | 16 | record = TestModel.find(1) 17 | expect(record).to be_a(TestModel) 18 | expect(record.id).to eq(1) 19 | end 20 | end 21 | 22 | describe '.create' do 23 | it 'sends POST request with entity key' do 24 | response = double('Response', parse: { id: 2, name: 'test' }) 25 | expect(magento_client).to receive(:post) 26 | .with('test_models', { 'test_model' => { name: 'test' } }) 27 | .and_return(response) 28 | 29 | record = TestModel.create(name: 'test') 30 | expect(record).to be_a(TestModel) 31 | expect(record.id).to eq(2) 32 | end 33 | end 34 | 35 | describe '.update' do 36 | it 'sends PUT request and builds record' do 37 | response = double('Response', parse: { id: 2, name: 'updated' }) 38 | expect(magento_client).to receive(:put) 39 | .with('test_models/2', { 'test_model' => { name: 'updated' } }) 40 | .and_return(response) 41 | 42 | record = TestModel.update(2, name: 'updated') 43 | expect(record).to be_a(TestModel) 44 | expect(record.name).to eq('updated') 45 | end 46 | end 47 | 48 | describe '.delete' do 49 | it 'sends DELETE request' do 50 | response = double('Response', status: double(success?: true)) 51 | expect(magento_client).to receive(:delete) 52 | .with('test_models/2') 53 | .and_return(response) 54 | 55 | expect(TestModel.delete(2)).to be true 56 | end 57 | end 58 | 59 | describe '#save' do 60 | it 'calls the update class method' do 61 | model = TestModel.new 62 | model.id = 5 63 | allow(model).to receive(:to_h).and_return(name: 'saved') 64 | expect(TestModel).to receive(:update) 65 | .with(5, { name: 'saved' }) 66 | .and_yield({}) 67 | 68 | model.save 69 | end 70 | end 71 | 72 | describe '#update' do 73 | it 'calls the update class method' do 74 | model = TestModel.new 75 | model.id = 8 76 | expect(TestModel).to receive(:update) 77 | .with(8, { name: 'upd' }) 78 | .and_yield({}) 79 | 80 | model.update(name: 'upd') 81 | end 82 | end 83 | 84 | describe '#delete' do 85 | it 'calls the delete class method' do 86 | model = TestModel.new 87 | model.id = 9 88 | expect(TestModel).to receive(:delete).with(9) 89 | model.delete 90 | end 91 | end 92 | 93 | describe '.api_resource' do 94 | it 'returns pluralized entity name by default' do 95 | expect(TestModel.api_resource).to eq('test_models') 96 | end 97 | 98 | it 'uses custom endpoint when set' do 99 | TestModel.send(:endpoint=, 'custom_endpoint') 100 | expect(TestModel.api_resource).to eq('custom_endpoint') 101 | TestModel.send(:endpoint=, nil) 102 | end 103 | end 104 | 105 | describe '.entity_name' do 106 | it do 107 | expect(TestModel.entity_name).to eq('test_model') 108 | end 109 | end 110 | 111 | describe '.primary_key' do 112 | it 'returns :id by default and custom value when set' do 113 | expect(TestModel.primary_key).to eq(:id) 114 | TestModel.send(:primary_key=, :uuid) 115 | expect(TestModel.primary_key).to eq(:uuid) 116 | TestModel.send(:primary_key=, nil) 117 | end 118 | end 119 | 120 | describe 'delegated methods from query' do 121 | it 'responds to delegated query methods' do 122 | %i[all find_each page per page_size order select where first find_by count].each do |method| 123 | expect(TestModel).to respond_to(method) 124 | end 125 | end 126 | end 127 | end 128 | 129 | describe 'protected method' do 130 | describe '.entity_key' do 131 | it 'returns entity name by default and custom value when set' do 132 | expect(TestModel.send(:entity_key)).to eq('test_model') 133 | TestModel.send(:entity_key=, 'my_key') 134 | expect(TestModel.send(:entity_key)).to eq('my_key') 135 | TestModel.send(:entity_key=, nil) 136 | end 137 | end 138 | 139 | describe '.query' do 140 | it 'returns a Magento::Query instance' do 141 | expect(TestModel.send(:query)).to be_a(Magento::Query) 142 | end 143 | end 144 | 145 | describe '.request' do 146 | it 'returns a Magento::Request instance' do 147 | allow(TestModel).to receive(:request).and_call_original 148 | expect(TestModel.send(:request)).to be_a(Magento::Request) 149 | end 150 | end 151 | end 152 | end -------------------------------------------------------------------------------- /lib/magento/params/create_product.rb: -------------------------------------------------------------------------------- 1 | require_relative 'create_image' 2 | require_relative 'create_custom_attribute' 3 | 4 | module Magento 5 | module Params 6 | # Example 7 | # 8 | # params = Magento::Params::CreateProduct.new( 9 | # sku: '556-teste-builder', 10 | # name: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 11 | # description: 'Descrição do produto', 12 | # brand: 'Coca-Cola', 13 | # price: 4.99, 14 | # special_price: 3.49, 15 | # quantity: 2, 16 | # weight: 0.3, 17 | # attribute_set_id: 4, 18 | # images: [ 19 | # *Magento::Params::CreateImage.new( 20 | # path: 'https://urltoimage.com/image.jpg', 21 | # title: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 22 | # position: 0, 23 | # main: true 24 | # ).variants, # it's generate all variants thumbnail => '100x100', small_image => '300x300' and image => '800x800' 25 | # Magento::Params::CreateImage.new( 26 | # path: '/path/to/image.jpg', 27 | # title: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 28 | # position: 1 29 | # ) 30 | # ] 31 | # ) 32 | # 33 | # Magento::Product.create(params.to_h) 34 | # 35 | class CreateProduct < Dry::Struct 36 | ProductTypes = Type::String.default('simple'.freeze).enum( 37 | 'simple', 38 | 'bundle', 39 | 'configurable', 40 | 'downloadable', 41 | 'grouped', 42 | 'Virtual' 43 | ) 44 | 45 | Visibilities = Type::String.default('catalog_and_search'.freeze).enum( 46 | 'not_visible_individually' => 1, 47 | 'catalog' => 1, 48 | 'search' => 3, 49 | 'catalog_and_search' => 4 50 | ) 51 | 52 | Statuses = Type::String.default('enabled'.freeze).enum('enabled' => 1, 'disabled' => 2) 53 | 54 | attribute :sku, Type::String 55 | attribute :name, Type::String 56 | attribute :description, Type::String 57 | attribute :brand, Type::String.optional.default(nil) 58 | attribute :price, Type::Coercible::Float 59 | attribute :special_price, Type::Float.optional.default(nil) 60 | attribute :attribute_set_id, Type::Integer 61 | attribute :status, Statuses 62 | attribute :visibility, Visibilities 63 | attribute :type_id, ProductTypes 64 | attribute :weight, Type::Coercible::Float 65 | attribute :quantity, Type::Coercible::Float 66 | attribute :featured, Type::String.default('0'.freeze).enum('0', '1') 67 | attribute :is_qty_decimal, Type::Bool.default(false) 68 | attribute :manage_stock, Type::Bool.default(true) 69 | attribute :category_ids, Type::Array.of(Type::Integer).default([].freeze) 70 | attribute :images, Type::Array.of(Type::Instance(CreateImage)).default([].freeze) 71 | attribute :website_ids, Type::Array.of(Type::Integer).default([0].freeze) 72 | attribute :custom_attributes, Type::Array.default([], shared: true) do 73 | attribute :attribute_code, Type::String 74 | attribute :value, Type::Coercible::String 75 | end 76 | 77 | alias orig_custom_attributes custom_attributes 78 | 79 | def to_h 80 | { 81 | sku: sku, 82 | name: name.titlecase, 83 | price: price, 84 | status: Statuses.mapping[status], 85 | visibility: Visibilities.mapping[visibility], 86 | type_id: type_id, 87 | weight: weight, 88 | attribute_set_id: attribute_set_id, 89 | extension_attributes: { 90 | website_ids: website_ids, 91 | category_links: categories, 92 | stock_item: stock 93 | }, 94 | media_gallery_entries: images.map(&:to_h), 95 | custom_attributes: custom_attributes.map(&:to_h) 96 | } 97 | end 98 | 99 | def stock 100 | { 101 | qty: quantity, 102 | is_in_stock: quantity.to_i > 0, 103 | is_qty_decimal: is_qty_decimal, 104 | show_default_notification_message: false, 105 | use_config_min_qty: true, 106 | min_qty: 1, 107 | use_config_min_sale_qty: 0, 108 | min_sale_qty: 0, 109 | use_config_max_sale_qty: true, 110 | max_sale_qty: 0, 111 | use_config_backorders: true, 112 | backorders: 0, 113 | use_config_notify_stock_qty: true, 114 | notify_stock_qty: 0, 115 | use_config_qty_increments: true, 116 | qty_increments: 0, 117 | use_config_enable_qty_inc: true, 118 | enable_qty_increments: true, 119 | use_config_manage_stock: manage_stock, 120 | manage_stock: manage_stock, 121 | low_stock_date: 'string', 122 | is_decimal_divided: is_qty_decimal, 123 | stock_status_changed_auto: 0 124 | } 125 | end 126 | 127 | def custom_attributes 128 | default_attributes = [ 129 | CustomAttribute.new(attribute_code: 'description', value: description), 130 | CustomAttribute.new(attribute_code: 'url_key', value: name.parameterize ), 131 | CustomAttribute.new(attribute_code: 'featured', value: featured) 132 | ] 133 | 134 | default_attributes.push(CustomAttribute.new(attribute_code: 'product_brand', value: brand)) if brand 135 | 136 | if special_price.to_f > 0 137 | default_attributes << CustomAttribute.new(attribute_code: 'special_price', value: special_price.to_s) 138 | end 139 | 140 | default_attributes + orig_custom_attributes 141 | end 142 | 143 | def categories 144 | category_ids.map { |c| { "category_id": c, "position": 0 } } 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/magento/cart.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class Cart < Model 5 | self.endpoint = 'carts' 6 | self.primary_key = :id 7 | 8 | # 9 | # Add a item to the current cart. 10 | # 11 | # Example: 12 | # 13 | # cart = Magento::Cart.find(1) 14 | # cart.add_item({ 15 | # sku: '123456', 16 | # qty: 1 17 | # }) 18 | # 19 | # @return Magento::Item: Added item 20 | def add_item(item_attributes) 21 | attributes = { cartItem: item_attributes.merge(quote_id: id) } 22 | item = self.class.add_item(id, attributes) 23 | @items = @items.reject { |i| i.item_id == item.item_id} << item if @items 24 | item 25 | end 26 | 27 | def delete_item(item_id) 28 | self.class.delete_item(id, item_id) 29 | end 30 | 31 | def delete_items 32 | self.class.delete_items(id) 33 | end 34 | 35 | def get_items 36 | self.class.get_items(id) 37 | end 38 | 39 | def update_item(item_id, item_attributes) 40 | attributes = { cartItem: item_attributes.merge(quote_id: id) } 41 | item = self.class.update_item(id, item_id, attributes) 42 | @items = @items.reject { |i| i.item_id == item.item_id} << item if @items 43 | item 44 | end 45 | 46 | # 47 | # Add a coupon by code to the current cart. 48 | # 49 | # Example: 50 | # 51 | # cart = Magento::Cart.find(1) 52 | # cart.add_coupon('COAU4HXE0I') 53 | # 54 | # @return Boolean: true on success, false otherwise 55 | def add_coupon(coupon) 56 | self.class.add_coupon(id, coupon) 57 | end 58 | 59 | # 60 | # Delete cart's coupon 61 | # 62 | # Example: 63 | # 64 | # cart = Magento::Cart.find(1) 65 | # cart.delete_coupon() 66 | # 67 | # @return Boolean: true on success, raise exception otherwise 68 | def delete_coupon 69 | self.class.delete_coupon(id) 70 | end 71 | 72 | def shipping_information(shipping_address:, billing_address:) 73 | self.class.shipping_information(id, shipping_address: shipping_address, billing_address: billing_address) 74 | end 75 | 76 | # 77 | # Place order for cart 78 | # 79 | # Example: 80 | # 81 | # cart = Magento::Cart.find('12345') 82 | # 83 | # # or use "build" to not request information from the magento API 84 | # cart = Magento::GuestCart.build({ 'cart_id' => '12345' }) 85 | # 86 | # cart.order( 87 | # email: 'customer@gmail.com', 88 | # payment: { method: 'cashondelivery' } 89 | # ) 90 | # 91 | # @return String: return the order id 92 | def order(email:, payment:) 93 | attributes = { cartId: id, paymentMethod: payment, email: email } 94 | self.class.order(attributes) 95 | end 96 | 97 | class << self 98 | # 99 | # Example: 100 | # 101 | # Magento::Cart.create({customer_id: 1}) 102 | # 103 | # @return Magento::Cart: Cart object for customer 104 | def create(attributes) 105 | id = request.post("#{api_resource}/mine", attributes).parse 106 | find id 107 | end 108 | 109 | def add_item(id, attributes) 110 | url = "#{api_resource}/#{id}/items" 111 | hash = request.post(url, attributes).parse 112 | Magento::ModelMapper.map_hash(Magento::Item, hash) 113 | end 114 | 115 | def delete_item(id, item_id) 116 | url = "#{api_resource}/#{id}/items/#{item_id}" 117 | request.delete(url).parse 118 | end 119 | 120 | def delete_items(id) 121 | items = get_items(id) 122 | 123 | items.each do |item| 124 | delete_item(id, item.item_id) 125 | end 126 | end 127 | 128 | def get_items(id) 129 | url = "#{api_resource}/#{id}/items" 130 | hash = request.get(url).parse 131 | Magento::ModelMapper.map_array('Item', hash) 132 | end 133 | 134 | def update_item(id, item_id, attributes) 135 | url = "#{api_resource}/#{id}/items/#{item_id}" 136 | hash = request.put(url, attributes).parse 137 | Magento::ModelMapper.map_hash(Magento::Item, hash) 138 | end 139 | 140 | # 141 | # Add a coupon by code to a specified cart. 142 | # 143 | # Example: 144 | # 145 | # Magento::Cart.add_coupon( 146 | # 1, 147 | # 'COAU4HXE0I' 148 | # ) 149 | # 150 | # @return Boolean: true on success, false otherwise 151 | def add_coupon(id, coupon) 152 | url = "#{api_resource}/#{id}/coupons/#{coupon}" 153 | request.put(url, nil).parse 154 | end 155 | 156 | # 157 | # Delete a coupon from a specified cart. 158 | # 159 | # Example: 160 | # 161 | # Magento::Cart.delete_coupon(1) 162 | # 163 | # @return Boolean: true on success, raise exception otherwise 164 | def delete_coupon(id) 165 | url = "#{api_resource}/#{id}/coupons" 166 | request.delete(url).parse 167 | end 168 | 169 | def shipping_information(id, shipping_address:, billing_address:) 170 | url = "#{api_resource}/#{id}/shipping-information" 171 | attributes = { 172 | addressInformation: { 173 | shipping_address: shipping_address, 174 | billing_address: billing_address 175 | } 176 | } 177 | request.post(url, attributes).parse 178 | end 179 | 180 | # 181 | # Place order for cart 182 | # 183 | # Example: 184 | # 185 | # Magento::Cart.order( 186 | # cartId: '12345', 187 | # paymentMethod: { method: 'cashondelivery' }, 188 | # email: email 189 | # ) 190 | # 191 | # @return String: return the order id 192 | def order(attributes) 193 | attributes = attributes.transform_keys(&:to_sym) 194 | url = "#{api_resource}/#{attributes[:cartId]}/order" 195 | request.put(url, attributes).parse 196 | end 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /lib/magento/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cgi' 4 | 5 | module Magento 6 | class Query 7 | ACCEPTED_CONDITIONS = [ 8 | 'eq', # Equals. 9 | 'finset', # A value within a set of values 10 | 'from', # The beginning of a range. Must be used with to 11 | 'gt', # Greater than 12 | 'gteq', # Greater than or equal 13 | 'in', # In. The value can contain a comma-separated list of values. 14 | 'like', # Like. The value can contain the SQL wildcard characters when like is specified. 15 | 'lt', # Less than 16 | 'lteq', # Less than or equal 17 | 'moreq', # More or equal 18 | 'neq', # Not equal 19 | 'nfinset', # A value that is not within a set of values 20 | 'nin', # Not in. The value can contain a comma-separated list of values. 21 | 'notnull', # Not null 22 | 'null', # Null 23 | 'to' # The end of a range. Must be used with from 24 | ].freeze 25 | 26 | def initialize(model, request: Request.new, api_resource: nil) 27 | @model = model 28 | @request = request 29 | @filter_groups = nil 30 | @current_page = 1 31 | @page_size = 50 32 | @sort_orders = nil 33 | @fields = nil 34 | @endpoint = api_resource || model.api_resource 35 | end 36 | 37 | def where(attributes) 38 | self.filter_groups = [] unless filter_groups 39 | filters = [] 40 | attributes.each do |key, value| 41 | field, condition = parse_filter(key) 42 | value = parse_value_filter(condition, value) 43 | filters << { field: field, conditionType: condition, value: value } 44 | end 45 | filter_groups << { filters: filters } 46 | self 47 | end 48 | 49 | def page(current_page) 50 | self.current_page = current_page 51 | self 52 | end 53 | 54 | def page_size(page_size) 55 | @page_size = page_size 56 | self 57 | end 58 | 59 | alias_method :per, :page_size 60 | 61 | def select(*fields) 62 | fields = fields.map { |field| parse_field(field, root: true) } 63 | 64 | if model == Magento::Category 65 | self.fields = "children_data[#{fields.join(',')}]" 66 | else 67 | self.fields = "items[#{fields.join(',')}],search_criteria,total_count" 68 | end 69 | 70 | self 71 | end 72 | 73 | def order(*attributes) 74 | self.sort_orders = [] 75 | attributes.each do |sort_order| 76 | if sort_order.is_a?(String) || sort_order.is_a?(Symbol) 77 | sort_orders << { field: verify_id(sort_order), direction: :asc } 78 | elsif sort_order.is_a?(Hash) 79 | sort_order.each do |field, direction| 80 | raise "Invalid sort order direction '#{direction}'" unless %w[asc desc].include?(direction.to_s) 81 | 82 | sort_orders << { field: verify_id(field), direction: direction } 83 | end 84 | end 85 | end 86 | self 87 | end 88 | 89 | def all 90 | result = request.get("#{endpoint}?#{query_params}").parse 91 | if model == Magento::Category 92 | model.build(result) 93 | else 94 | RecordCollection.from_magento_response(result, model: model) 95 | end 96 | end 97 | 98 | # 99 | # Loop all products on each page, starting from the first to the last page 100 | def find_each 101 | if @model == Magento::Category 102 | raise NoMethodError, 'undefined method `find_each` for Magento::Category' 103 | end 104 | 105 | @current_page = 1 106 | 107 | loop do 108 | redords = all 109 | 110 | redords.each do |record| 111 | yield record 112 | end 113 | 114 | break if redords.last_page? 115 | 116 | @current_page = redords.next_page 117 | end 118 | end 119 | 120 | def first 121 | page_size(1).page(1).all.first 122 | end 123 | 124 | def find_by(attributes) 125 | where(attributes).first 126 | end 127 | 128 | def count 129 | select(:id).page_size(1).page(1).all.total_count 130 | end 131 | 132 | private 133 | 134 | attr_accessor :current_page, :filter_groups, :request, :sort_orders, :model, :fields 135 | 136 | def endpoint 137 | @endpoint 138 | end 139 | 140 | def verify_id(field) 141 | return model.primary_key if (field.to_s == 'id') && (field.to_s != model.primary_key.to_s) 142 | 143 | field 144 | end 145 | 146 | def query_params 147 | query = { 148 | searchCriteria: { 149 | filterGroups: filter_groups, 150 | currentPage: current_page, 151 | sortOrders: sort_orders, 152 | pageSize: @page_size 153 | }.compact, 154 | fields: fields 155 | }.compact 156 | 157 | encode query 158 | end 159 | 160 | def parse_filter(key) 161 | patter = /(.*)_([a-z]+)$/ 162 | match = key.match(patter) 163 | 164 | return match.to_a[1..2] if match && ACCEPTED_CONDITIONS.include?(match[2]) 165 | 166 | [key, 'eq'] 167 | end 168 | 169 | def parse_value_filter(condition, value) 170 | if ['in', 'nin'].include?(condition) && value.is_a?(Array) 171 | value = value.join(',') 172 | end 173 | 174 | value 175 | end 176 | 177 | def parse_field(value, root: false) 178 | return (root ? verify_id(value) : value) unless value.is_a? Hash 179 | 180 | value.map do |k, v| 181 | fields = v.is_a?(Array) ? v.map { |field| parse_field(field) } : [parse_field(v)] 182 | "#{k}[#{fields.join(',')}]" 183 | end.join(',') 184 | end 185 | 186 | def encode(value, key = nil) 187 | case value 188 | when Hash then value.map { |k, v| encode(v, append_key(key, k)) }.join('&') 189 | when Array then value.each_with_index.map { |v, i| encode(v, "#{key}[#{i}]") }.join('&') 190 | when nil then '' 191 | else 192 | "#{key}=#{CGI.escape(value.to_s)}" 193 | end 194 | end 195 | 196 | def append_key(root_key, key) 197 | root_key.nil? ? key : "#{root_key}[#{key}]" 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/magento/order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Magento 4 | class Order < Model 5 | self.primary_key = :entity_id 6 | self.entity_key = :entity 7 | 8 | def save 9 | raise NotImplementedError 10 | end 11 | 12 | def update(attrs) 13 | raise "'entity_id' not found" if @entity_id.nil? 14 | 15 | self.class.update(@entity_id, attrs) do |hash| 16 | update_attributes(hash) 17 | end 18 | end 19 | 20 | def cancel 21 | self.class.cancel(id) 22 | end 23 | 24 | # 25 | # Invoice current order 26 | # 27 | # order = Magento::Order.find(order_id) 28 | # 29 | # order.invoice # or you can pass parameters 30 | # order.invoice(capture: false) # See the invoice class method for more information 31 | # 32 | # @return String: return the invoice id 33 | def invoice(params=nil) 34 | self.class.invoice(id, params) 35 | end 36 | 37 | # 38 | # Create offline refund for order 39 | # 40 | # order = Magento::Order.find(order_id) 41 | # 42 | # order.refund # or you can pass parameters 43 | # order.invoice(notify: false) # See the refund class method for more information 44 | # 45 | # @return {Integer} return the refund id 46 | def refund(refund_params = nil) 47 | self.class.refund(id, refund_params) 48 | end 49 | 50 | # 51 | # Creates new Shipment for given Order. 52 | # 53 | # order = Magento::Order.find(order_id) 54 | # 55 | # order.ship # or you can pass parameters 56 | # order.ship(notify: false) # See the shipment class method for more information 57 | # 58 | # Return the shipment id 59 | def ship(params=nil) 60 | self.class.ship(id, params) 61 | end 62 | 63 | def send_email 64 | self.class.send_email(id) 65 | end 66 | 67 | # 68 | # Creates a comment on the given Order 69 | # 70 | # order = Magento::Order.find(order_id) 71 | # 72 | # order.add_comment( 73 | # 'comment', 74 | # is_customer_notified: 0, 75 | # is_visible_on_front: 1 76 | # ) 77 | # 78 | # Return true on success 79 | def add_comment(comment, comment_params = nil) 80 | self.class.add_comment(id, comment, comment_params) 81 | end 82 | 83 | class << self 84 | def update(entity_id, attributes) 85 | attributes[:entity_id] = entity_id 86 | hash = request.put('orders/create', { entity_key => attributes }).parse 87 | block_given? ? yield(hash) : build(hash) 88 | end 89 | 90 | # @return {Boolean} 91 | def cancel(order_id) 92 | request.post("orders/#{order_id}/cancel").parse 93 | end 94 | 95 | # 96 | # Invoice an order 97 | # 98 | # Magento::Order.invoice(order_id) 99 | # 100 | # or 101 | # 102 | # Magento::Order.invoice( 103 | # order_id, 104 | # capture: false, 105 | # appendComment: true, 106 | # items: [{ order_item_id: 123, qty: 1 }], # pass items to partial invoice 107 | # comment: { 108 | # extension_attributes: { }, 109 | # comment: "string", 110 | # is_visible_on_front: 0 111 | # }, 112 | # notify: true 113 | # ) 114 | # 115 | # to complete [documentation](https://magento.redoc.ly/2.4-admin/tag/orderorderIdinvoice#operation/salesInvoiceOrderV1ExecutePost) 116 | # 117 | # @return String: return the invoice id 118 | def invoice(order_id, invoice_params=nil) 119 | request.post("order/#{order_id}/invoice", invoice_params).parse 120 | end 121 | 122 | # 123 | # Create offline refund for order 124 | # 125 | # Magento::Order.refund(order_id) 126 | # 127 | # or 128 | # 129 | # Magento::Order.refund( 130 | # order_id, 131 | # items: [ 132 | # { 133 | # extension_attributes: {}, 134 | # order_item_id: 0, 135 | # qty: 0 136 | # } 137 | # ], 138 | # notify: true, 139 | # appendComment: true, 140 | # comment: { 141 | # extension_attributes: {}, 142 | # comment: string, 143 | # is_visible_on_front: 0 144 | # }, 145 | # arguments: { 146 | # shipping_amount: 0, 147 | # adjustment_positive: 0, 148 | # adjustment_negative: 0, 149 | # extension_attributes: { 150 | # return_to_stock_items: [ 151 | # 0 152 | # ] 153 | # } 154 | # } 155 | # ) 156 | # 157 | # to complete [documentation](https://magento.redoc.ly/2.4-admin/tag/invoicescomments#operation/salesRefundOrderV1ExecutePost) 158 | # 159 | # @return {Integer} return the refund id 160 | def refund(order_id, refund_params = nil) 161 | request.post("order/#{order_id}/refund", refund_params).parse 162 | end 163 | 164 | # 165 | # Creates new Shipment for given Order. 166 | # 167 | # Magento::Order.ship(order_id) 168 | # 169 | # or 170 | # 171 | # Magento::Order.ship( 172 | # order_id, 173 | # capture: false, 174 | # appendComment: true, 175 | # items: [{ order_item_id: 123, qty: 1 }], # pass items to partial shipment 176 | # tracks: [ 177 | # { 178 | # extension_attributes: { }, 179 | # track_number: "string", 180 | # title: "string", 181 | # carrier_code: "string" 182 | # } 183 | # ] 184 | # notify: true 185 | # ) 186 | # 187 | # to complete [documentation](https://magento.redoc.ly/2.4-admin/tag/orderorderIdship#operation/salesShipOrderV1ExecutePost) 188 | # 189 | # @return {String}: return the shipment id 190 | def ship(order_id, shipment_params = nil) 191 | request.post("order/#{order_id}/ship", shipment_params).parse 192 | end 193 | 194 | def send_email(order_id) 195 | request.post("orders/#{order_id}/emails").parse 196 | end 197 | 198 | # 199 | # Creates a comment on the given Order 200 | # 201 | # Magento::Order.add_comment( 202 | # order_id, 203 | # 'comment', 204 | # is_customer_notified: 0, 205 | # is_visible_on_front: 1 206 | # ) 207 | # 208 | # to complete [documentation](https://magento.redoc.ly/2.4.2-admin/tag/ordersidcomments#operation/salesOrderManagementV1AddCommentPost) 209 | # 210 | # @return {Boolean}: return true on success 211 | def add_comment(order_id, comment, comment_params = nil) 212 | request.post( 213 | "orders/#{order_id}/comments", 214 | statusHistory: { 215 | comment: comment, 216 | **comment_params 217 | } 218 | ).parse 219 | end 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /lib/magento/product.rb: -------------------------------------------------------------------------------- 1 | module Magento 2 | class Product < Model 3 | self.primary_key = :sku 4 | 5 | def method_missing(m, *params, &block) 6 | attr(m) || super(m, *params, &block) 7 | end 8 | 9 | def stock 10 | extension_attributes&.stock_item 11 | end 12 | 13 | def stock_quantity 14 | stock&.qty 15 | end 16 | 17 | # returns custom_attribute value by custom_attribute code 18 | # return nil if custom_attribute is not present 19 | def attr(attribute_code) 20 | @custom_attributes&.find { |a| a.attribute_code == attribute_code.to_s }&.value 21 | end 22 | 23 | def set_custom_attribute(code, value) 24 | @custom_attributes ||= [] 25 | attribute = @custom_attributes.find { |a| a.attribute_code == code.to_s } 26 | 27 | if attribute 28 | attribute.value = value 29 | else 30 | @custom_attributes << Magento::CustomAttribute.build( 31 | attribute_code: code.to_s, 32 | value: value 33 | ) 34 | end 35 | end 36 | 37 | def respond_to?(attribute_code) 38 | super || @custom_attributes&.any? { |a| a.attribute_code == attribute_code.to_s } 39 | end 40 | 41 | # Create new gallery entry 42 | # 43 | # Example: 44 | # 45 | # product = Magento::Product.find('sku') 46 | # 47 | # product.add_media( 48 | # media_type: 'image', 49 | # label: 'Image label', 50 | # position: 1, 51 | # content: { 52 | # base64_encoded_data: 'image-string-base64', 53 | # type: 'image/jpg', 54 | # name: 'filename.jpg' 55 | # }, 56 | # types: ['image'] 57 | # ) 58 | # 59 | # Or you can use the Magento::Params::CreateImage helper class 60 | # 61 | # params = Magento::Params::CreateImage.new( 62 | # title: 'Image title', 63 | # path: '/path/to/image.jpg', # or url 64 | # position: 1, 65 | # ).to_h 66 | # 67 | # product.add_media(params) 68 | # 69 | def add_media(attributes) 70 | self.class.add_media(sku, attributes) 71 | end 72 | 73 | # returns true if the media was deleted 74 | def remove_media(media_id) 75 | self.class.remove_media(sku, media_id) 76 | end 77 | 78 | # Add {price} on product {sku} for specified {customer_group_id} 79 | # 80 | # Param {quantity} is the minimum amount to apply the price 81 | # 82 | # product = Magento::Product.find(1) 83 | # product.add_tier_price(3.99, quantity: 1, customer_group_id: :all) 84 | # 85 | # OR 86 | # 87 | # Magento::Product.add_tier_price(1, 3.99, quantity: 1, customer_group_id: :all) 88 | # 89 | # @return {Boolean} 90 | def add_tier_price(price, quantity:, customer_group_id: :all) 91 | self.class.add_tier_price( 92 | sku, price, quantity: quantity, customer_group_id: customer_group_id 93 | ) 94 | end 95 | 96 | # 97 | # Remove tier price 98 | # 99 | # product = Magento::Product.find(1) 100 | # product.remove_tier_price(quantity: 1, customer_group_id: :all) 101 | # 102 | # @return {Boolean} 103 | def remove_tier_price(quantity:, customer_group_id: :all) 104 | self.class.remove_tier_price( 105 | sku, quantity: quantity, customer_group_id: customer_group_id 106 | ) 107 | end 108 | 109 | # Update product stock 110 | # 111 | # product = Magento::Product.find('sku') 112 | # product.update_stock(qty: 12, is_in_stock: true) 113 | # 114 | # see all available attributes in: https://magento.redoc.ly/2.4.1-admin/tag/productsproductSkustockItemsitemId 115 | def update_stock(attributes) 116 | self.class.update_stock(sku, id, attributes) 117 | end 118 | 119 | # Assign a product link to another product 120 | # 121 | # product = Magento::Product.find('sku') 122 | # 123 | # product.create_links([ 124 | # { 125 | # link_type: 'upsell', 126 | # linked_product_sku: 'linked_product_sku', 127 | # linked_product_type: 'simple', 128 | # position: position, 129 | # sku: 'product-sku' 130 | # } 131 | # ]) 132 | # 133 | def create_links(product_links) 134 | self.class.create_links(sku, product_links) 135 | end 136 | 137 | def remove_link(link_type:, linked_product_sku:) 138 | self.class.remove_link(sku, link_type: link_type, linked_product_sku: linked_product_sku) 139 | end 140 | 141 | class << self 142 | alias_method :find_by_sku, :find 143 | 144 | # Create new gallery entry 145 | # 146 | # Example: 147 | # 148 | # Magento::Product.add_media('sku', { 149 | # media_type: 'image', 150 | # label: 'Image title', 151 | # position: 1, 152 | # content: { 153 | # base64_encoded_data: 'image-string-base64', 154 | # type: 'image/jpg', 155 | # name: 'filename.jpg' 156 | # }, 157 | # types: ['image'] 158 | # }) 159 | # 160 | # Or you can use the Magento::Params::CreateImage helper class 161 | # 162 | # params = Magento::Params::CreateImage.new( 163 | # title: 'Image title', 164 | # path: '/path/to/image.jpg', # or url 165 | # position: 1, 166 | # ).to_h 167 | # 168 | # Magento::Product.add_media('sku', params) 169 | # 170 | def add_media(sku, attributes) 171 | request.post("products/#{sku}/media", { entry: attributes }).parse 172 | end 173 | 174 | # returns true if the media was deleted 175 | def remove_media(sku, media_id) 176 | request.delete("products/#{sku}/media/#{media_id}").parse 177 | end 178 | 179 | # Add {price} on product {sku} for specified {customer_group_id} 180 | # 181 | # Param {quantity} is the minimum amount to apply the price 182 | # 183 | # @return {Boolean} 184 | def add_tier_price(sku, price, quantity:, customer_group_id: :all) 185 | request.post( 186 | "products/#{sku}/group-prices/#{customer_group_id}/tiers/#{quantity}/price/#{price}" 187 | ).parse 188 | end 189 | 190 | # Remove tier price 191 | # 192 | # Product.remove_tier_price('sku', quantity: 1, customer_group_id: :all) 193 | # 194 | # @return {Boolean} 195 | def remove_tier_price(sku, quantity:, customer_group_id: :all) 196 | request.delete( 197 | "products/#{sku}/group-prices/#{customer_group_id}/tiers/#{quantity}" 198 | ).parse 199 | end 200 | 201 | # Update product stock 202 | # 203 | # Magento::Product.update_stock(sku, id, { 204 | # qty: 12, 205 | # is_in_stock: true 206 | # }) 207 | # 208 | # see all available attributes in: https://magento.redoc.ly/2.4.1-admin/tag/productsproductSkustockItemsitemId 209 | def update_stock(sku, id, attributes) 210 | request.put("products/#{sku}/stockItems/#{id}", stockItem: attributes).parse 211 | end 212 | 213 | # Assign a product link to another product 214 | # 215 | # Product.create_links('product-sku', [ 216 | # { 217 | # link_type: 'upsell', 218 | # linked_product_sku: 'linked_product_sku', 219 | # linked_product_type: 'simple', 220 | # position: position, 221 | # sku: 'product-sku' 222 | # } 223 | # ]) 224 | # 225 | def create_links(sku, product_links) 226 | request.post("products/#{sku}/links", { items: product_links }) 227 | end 228 | 229 | def remove_link(sku, link_type:, linked_product_sku:) 230 | request.delete("products/#{sku}/links/#{link_type}/#{linked_product_sku}") 231 | end 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /spec/magento/core/query_spec.rb: -------------------------------------------------------------------------------- 1 | class Magento::Faker < Magento::Model; end 2 | 3 | RSpec.describe Magento::Query do 4 | subject { Magento::Query.new(Magento::Faker) } 5 | 6 | describe '#where' do 7 | it 'add the filter to group of filters' do 8 | subject.where(price_gt: 50) 9 | 10 | expect(subject.send(:filter_groups)).to eql([ 11 | { filters: [{ field: 'price', conditionType: 'gt', value: 50 }] } 12 | ]) 13 | end 14 | 15 | context 'when the condition is not passed' do 16 | it 'the "eq" condition is used as default' do 17 | subject.where(price: 50) 18 | 19 | expect(subject.send(:filter_groups)).to eql([ 20 | { filters: [{ field: :price, conditionType: 'eq', value: 50 }] } 21 | ]) 22 | end 23 | end 24 | 25 | context 'when it is called more than once' do 26 | it 'adds filter in diferent groups' do 27 | subject.where(price_gt: 10).where(price_lt: 20) 28 | 29 | expect(subject.send(:filter_groups)).to eql([ 30 | { filters: [{ field: 'price', conditionType: 'gt', value: 10 }] }, 31 | { filters: [{ field: 'price', conditionType: 'lt', value: 20 }] } 32 | ]) 33 | end 34 | end 35 | 36 | context 'when it is called with more than one filter' do 37 | it 'adds the filters in same group' do 38 | subject.where(price_gt: 10, price_lt: 20) 39 | 40 | expect(subject.send(:filter_groups)).to eql([ 41 | { 42 | filters: [ 43 | { field: 'price', conditionType: 'gt', value: 10 }, 44 | { field: 'price', conditionType: 'lt', value: 20 } 45 | ] 46 | } 47 | ]) 48 | end 49 | end 50 | 51 | context 'when the condition is "in" or "nin" and value is a Array' do 52 | it 'converts the value to string' do 53 | subject.where(status_in: [:pending, :new]) 54 | subject.where(entity_id_nin: [123, 321]) 55 | 56 | expect(subject.send(:filter_groups)).to eql([ 57 | { filters: [{ field: 'status', conditionType: 'in', value: 'pending,new' }] }, 58 | { filters: [{ field: 'entity_id', conditionType: 'nin', value: '123,321' }] } 59 | ]) 60 | end 61 | end 62 | end 63 | 64 | describe '#page' do 65 | it do 66 | subject.page(2) 67 | expect(subject.send(:current_page)).to eql(2) 68 | end 69 | end 70 | 71 | describe '#page_size' do 72 | it do 73 | subject.page_size(5) 74 | expect(subject.instance_variable_get(:@page_size)).to eql(5) 75 | end 76 | end 77 | 78 | describe '#select' do 79 | it 'set fields inside items[]' do 80 | subject.select(:id, :name) 81 | 82 | expect(subject.send(:fields)).to eql('items[id,name],search_criteria,total_count') 83 | end 84 | 85 | it 'allow hash' do 86 | subject.select(:id, nested_attribute: :name) 87 | 88 | expect(subject.send(:fields)).to eql('items[id,nested_attribute[name]],search_criteria,total_count') 89 | end 90 | 91 | it 'allow hash with key and value as array' do 92 | subject.select(:id, nested_attribute: [:id, :name]) 93 | 94 | expect(subject.send(:fields)).to eql('items[id,nested_attribute[id,name]],search_criteria,total_count') 95 | end 96 | 97 | it 'allow hash multiple level' do 98 | subject.select(:id, nested_attribute: [:id, :name, stock: :quantity]) 99 | 100 | expect(subject.send(:fields)).to eql( 101 | 'items[id,nested_attribute[id,name,stock[quantity]]],search_criteria,total_count' 102 | ) 103 | end 104 | 105 | context 'when model is Magento::Category' do 106 | class Magento::Category < Magento::Model; end 107 | 108 | subject { Magento::Query.new(Magento::Category) } 109 | 110 | it 'set fields inseide children_data[]' do 111 | subject.select(:id, :name) 112 | 113 | expect(subject.send(:fields)).to eql('children_data[id,name]') 114 | end 115 | end 116 | end 117 | 118 | describe '#order' do 119 | it 'set order in sort_orders' do 120 | subject.order(name: :desc) 121 | 122 | expect(subject.send(:sort_orders)).to eql( 123 | [{ field: :name, direction: :desc }] 124 | ) 125 | 126 | subject.order(created_at: :desc, name: :asc) 127 | 128 | expect(subject.send(:sort_orders)).to eql( 129 | [ 130 | { field: :created_at, direction: :desc }, 131 | { field: :name, direction: :asc } 132 | ] 133 | ) 134 | end 135 | 136 | context 'when the direction is not passed' do 137 | it 'the :asc direction is used as default' do 138 | subject.order(:name) 139 | 140 | expect(subject.send(:sort_orders)).to eql( 141 | [{ field: :name, direction: :asc }] 142 | ) 143 | 144 | subject.order(:created_at, :name) 145 | 146 | expect(subject.send(:sort_orders)).to eql([ 147 | { field: :created_at, direction: :asc }, 148 | { field: :name, direction: :asc } 149 | ]) 150 | end 151 | end 152 | end 153 | 154 | describe '#all' do 155 | it 'requests the resource and returns a RecordCollection' do 156 | request = instance_double(Magento::Request) 157 | allow(subject).to receive(:request).and_return(request) 158 | response = double('Response', parse: { 159 | 'items' => [{ 'id' => 1 }], 160 | 'search_criteria' => { 'current_page' => 1, 'page_size' => 50 }, 161 | 'total_count' => 1 162 | }) 163 | expect(request).to receive(:get) 164 | .with('fakers?searchCriteria[currentPage]=1&searchCriteria[pageSize]=50') 165 | .and_return(response) 166 | 167 | records = subject.all 168 | expect(records).to be_a(Magento::RecordCollection) 169 | expect(records.first).to be_a(Magento::Faker) 170 | end 171 | end 172 | 173 | describe '#first' do 174 | it 'returns the first record' do 175 | request = instance_double(Magento::Request) 176 | allow(subject).to receive(:request).and_return(request) 177 | response = double('Response', parse: { 178 | 'items' => [{ 'id' => 1 }], 179 | 'search_criteria' => { 'current_page' => 1, 'page_size' => 1 }, 180 | 'total_count' => 1 181 | }) 182 | expect(request).to receive(:get) 183 | .with('fakers?searchCriteria[currentPage]=1&searchCriteria[pageSize]=1') 184 | .and_return(response) 185 | 186 | expect(subject.first).to be_a(Magento::Faker) 187 | end 188 | end 189 | 190 | describe '#find_by' do 191 | it 'builds query using where and returns first' do 192 | expect(subject).to receive(:where).with({ name: 'foo' }).and_return(subject) 193 | expect(subject).to receive(:first).and_return(:record) 194 | expect(subject.find_by(name: 'foo')).to eq(:record) 195 | end 196 | end 197 | 198 | describe '#count' do 199 | it 'uses select and returns total_count' do 200 | expect(subject).to receive(:select).with(:id).and_return(subject) 201 | expect(subject).to receive(:page_size).with(1).and_return(subject) 202 | expect(subject).to receive(:page).with(1).and_return(subject) 203 | expect(subject).to receive(:all).and_return(double(total_count: 5)) 204 | expect(subject.count).to eq(5) 205 | end 206 | end 207 | 208 | describe '#find_each' do 209 | it 'loops through pages yielding records' do 210 | page1 = Magento::RecordCollection.new( 211 | items: [1, 2], 212 | total_count: 5, 213 | search_criteria: Magento::SearchCriterium.build('current_page' => 1, 'page_size' => 2) 214 | ) 215 | page2 = Magento::RecordCollection.new( 216 | items: [3, 4], 217 | total_count: 5, 218 | search_criteria: Magento::SearchCriterium.build('current_page' => 2, 'page_size' => 2) 219 | ) 220 | page3 = Magento::RecordCollection.new( 221 | items: [5], 222 | total_count: 5, 223 | search_criteria: Magento::SearchCriterium.build('current_page' => 3, 'page_size' => 2) 224 | ) 225 | allow(subject).to receive(:all).and_return(page1, page2, page3) 226 | items = [] 227 | subject.find_each { |i| items << i } 228 | expect(items).to eq([1, 2, 3, 4, 5]) 229 | end 230 | 231 | it 'raises when model is Magento::Category' do 232 | query = Magento::Query.new(Magento::Category) 233 | expect { query.find_each { |_| } }.to raise_error(NoMethodError) 234 | end 235 | end 236 | 237 | describe 'private mathods' do 238 | describe 'endpoint' do 239 | it 'returns the endpoint passed in initializer' do 240 | q = Magento::Query.new(Magento::Faker, api_resource: 'custom') 241 | expect(q.send(:endpoint)).to eq('custom') 242 | end 243 | end 244 | 245 | describe 'verify_id' do 246 | it 'replaces id with primary_key when different' do 247 | class CustomModel < Magento::Model; end 248 | CustomModel.send(:primary_key=, :entity_id) 249 | q = Magento::Query.new(CustomModel) 250 | expect(q.send(:verify_id, :id)).to eq(:entity_id) 251 | expect(q.send(:verify_id, :name)).to eq(:name) 252 | end 253 | end 254 | 255 | describe 'query_params' do 256 | it 'returns encoded params from query state' do 257 | subject.where(name_like: 'John') 258 | subject.page(2) 259 | subject.page_size(10) 260 | subject.select(:id) 261 | subject.order(name: :desc) 262 | expected = 'searchCriteria[filterGroups][0][filters][0][field]=name&searchCriteria[filterGroups][0][filters][0][conditionType]=like&searchCriteria[filterGroups][0][filters][0][value]=John&searchCriteria[currentPage]=2&searchCriteria[sortOrders][0][field]=name&searchCriteria[sortOrders][0][direction]=desc&searchCriteria[pageSize]=10&fields=items%5Bid%5D%2Csearch_criteria%2Ctotal_count' 263 | expect(subject.send(:query_params)).to eq(expected) 264 | end 265 | end 266 | 267 | describe 'parse_filter' do 268 | it 'splits field and condition' do 269 | expect(subject.send(:parse_filter, :price_gt)).to eq(%w[price gt]) 270 | expect(subject.send(:parse_filter, :price)).to eq([:price, 'eq']) 271 | end 272 | end 273 | 274 | describe 'parse_value_filter' do 275 | it 'joins array when condition is in or nin' do 276 | expect(subject.send(:parse_value_filter, 'in', [1, 2])).to eq('1,2') 277 | expect(subject.send(:parse_value_filter, 'eq', [1, 2])).to eq([1, 2]) 278 | end 279 | end 280 | 281 | describe 'parse_field' do 282 | it 'formats nested fields' do 283 | expect(subject.send(:parse_field, :id, root: true)).to eq(:id) 284 | expect(subject.send(:parse_field, { nested: :name })).to eq('nested[name]') 285 | expect(subject.send(:parse_field, { nested: [:id, :name] })).to eq('nested[id,name]') 286 | end 287 | end 288 | 289 | describe 'encode' do 290 | it 'encodes hash params' do 291 | expected = 'a[b][c]=1&a[b][d]=2&arr[0]=1&arr[1]=2' 292 | result = subject.send(:encode, a: { b: { c: 1, d: 2 } }, arr: [1, 2]) 293 | expect(result).to eq(expected) 294 | end 295 | end 296 | 297 | describe 'append_key' do 298 | it do 299 | expect(subject.send(:append_key, nil, 'key')).to eq('key') 300 | expect(subject.send(:append_key, 'root', 'key')).to eq('root[key]') 301 | end 302 | end 303 | end 304 | end 305 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magento 2 Ruby library 2 | 3 | Ruby library to consume the magento 2 api 4 | 5 | > Tested in version 2.3 of magento 6 | 7 | [Getting started](#getting-started) 8 | - [Install](#install) 9 | - [Setup](#setup) 10 | 11 | [Model common methods](#model-common-methods) 12 | - [find](#find) 13 | - [find_by](#find_by) 14 | - [first](#first) 15 | - [count](#count) 16 | - [all](#count) 17 | - [create](#create) 18 | - [update](#update) 19 | - [delete](#delete) 20 | 21 | [Search criteria](#search-criteria) 22 | - [Select fields](#select-fields) 23 | - [Filters](#filters) 24 | - [Sort order](#sort-order) 25 | - [Pagination](#pagination) 26 | - [Record Collection](#record-collection) 27 | 28 | **Additional methods** 29 | 30 | [Product](#product) 31 | - [Shurtcuts](#shurtcuts) 32 | - [Update stock](#update-stock) 33 | - [Add media](#add-media-to-product) 34 | - [Remove media](#remove-media-from-product) 35 | - [Add tier price](#add-tier-price-to-product) 36 | - [Remove tier price](#remove-tier-price-from-product) 37 | - [Create links](#create-links-to-product) 38 | - [Remove link](#remove-link-from-product) 39 | 40 | [Order](#order) 41 | - [Invoice](#invoice-an-order) 42 | - [Offline Refund](#create-offline-refund-for-order) 43 | - [Creates new Shipment](#creates-new-shipment-for-given-order) 44 | - [Cancel](#cancel-an-order) 45 | 46 | [Invoice](#invoice) 47 | - [Refund](#create-refund-for-invoice) 48 | - [Capture](#capture-an-invoice) 49 | - [Void](#void-an-invoice) 50 | - [Send email](#send-invoice-email) 51 | - [Get comments](#get-invoice-comments) 52 | 53 | [Sales Rule](#sales-rule) 54 | - [Generate Sales Rules and Coupons](#generate-sales-rules-and-coupons) 55 | 56 | [Customer](#customer) 57 | - [Find by token](#get-customer-by-token) 58 | - [Login](#customer-login) 59 | - [Reset Password](#customer-reset-password) 60 | 61 | [Guest cart](#guest-cart) 62 | - [Payment information](#payment-information) 63 | - [Add Coupon](#add-coupon-to-guest-cart) 64 | - [Remove Coupon](#remove-coupon-from-guest-cart) 65 | 66 | [Inventory](#invoice) 67 | - [Check whether a product is salable](#check-whether-a-product-is-salable) 68 | - [Check whether a product is salable for a specified quantity](#check-whether-a-product-is-salable-for-a-specified-quantity) 69 | 70 | **Helper classes** 71 | 72 | - [Create product params](#create-product-params) 73 | - [Create product image params](#create-product-image-params) 74 | - [Import products from csv file](#import-products-from-csv-file) 75 | 76 | ## Getting started 77 | 78 | ### Install 79 | Add in your Gemfile 80 | 81 | ```rb 82 | gem 'magento', '~> 0.31.0' 83 | ``` 84 | 85 | or run 86 | 87 | ```sh 88 | gem install magento 89 | ``` 90 | 91 | ### Setup 92 | 93 | ```rb 94 | Magento.configure do |config| 95 | config.url = 'https://yourstore.com' 96 | config.token = 'MAGENTO_API_KEY' 97 | config.store = :default # optional, Default is :all 98 | end 99 | 100 | Magento.with_config(store: :other_store) do # accepts store, url and token parameters 101 | Magento::Product.find('sku') 102 | end 103 | ``` 104 | 105 | ## Model common methods 106 | 107 | All classes that inherit from `Magento::Model` have the methods described below 108 | 109 | ### `find` 110 | 111 | Get resource details with the `find` method 112 | 113 | Example: 114 | ```rb 115 | Magento::Product.find('sku-test') 116 | Magento::Order.find(25) 117 | Magento::Country.find('BR') 118 | ``` 119 | 120 | ### `find_by` 121 | 122 | Returns the first resource found based on the argument passed 123 | 124 | Example: 125 | ```rb 126 | Magento::Product.find_by(name: 'Some product name') 127 | Magento::Customer.find_by(email: 'customer@email.com') 128 | ``` 129 | 130 | ### `first` 131 | 132 | Returns the first resource found for the [search criteria](#search-criteria) 133 | 134 | Example: 135 | ```rb 136 | Magento::Order.where(grand_total_gt: 100).first 137 | ``` 138 | 139 | ### `count` 140 | 141 | Returns the total amount of the resource, also being able to use it based on the [search criteria](#search-criteria) 142 | 143 | Example: 144 | ```rb 145 | 146 | Magento::Order.count 147 | >> 1500 148 | 149 | Magento::Order.where(status: :pending).count 150 | >> 48 151 | ``` 152 | 153 | ### `all` 154 | 155 | Used to get a list of a specific resource based on the [search criteria](#search-criteria). 156 | 157 | Returns a [Record Collection](#record-collection) 158 | 159 | Example: 160 | ```rb 161 | # Default search criteria: 162 | # page: 1 163 | # page_size: 50 164 | Magento::Product.all 165 | 166 | Magento::Product 167 | .order(created_at: :desc) 168 | .page_size(10) 169 | .all 170 | ``` 171 | 172 | ### `create` 173 | 174 | Creates a new resource based on reported attributes. 175 | 176 | Consult the magento documentation for available attributes for each resource: 177 | 178 | Documentation links: 179 | - [Product](https://magento.redoc.ly/2.3.6-admin/tag/products#operation/catalogProductRepositoryV1SavePost) 180 | - [Category](https://magento.redoc.ly/2.3.6-admin/tag/categories#operation/catalogCategoryRepositoryV1SavePost) 181 | - [Order](https://magento.redoc.ly/2.3.6-admin/tag/orders#operation/salesOrderRepositoryV1SavePost) 182 | - [Customer](https://magento.redoc.ly/2.3.6-admin/tag/customers#operation/customerAccountManagementV1CreateAccountPost) 183 | 184 | Example: 185 | ```rb 186 | Magento::Order.create( 187 | customer_firstname: '', 188 | customer_lastname: '', 189 | customer_email: '', 190 | # others attrbutes ..., 191 | items: [ 192 | { 193 | sku: '', 194 | price: '', 195 | qty_ordered: 1, 196 | # others attrbutes ..., 197 | } 198 | ], 199 | billing_address: { 200 | # attrbutes... 201 | }, 202 | payment: { 203 | # attrbutes... 204 | }, 205 | extension_attributes: { 206 | # attrbutes... 207 | } 208 | ) 209 | ``` 210 | 211 | #### `update` 212 | 213 | Update a resource attributes. 214 | 215 | Example: 216 | 217 | ```rb 218 | Magento::Product.update('sku-teste', name: 'Updated name') 219 | 220 | # or by instance method 221 | 222 | product = Magento::Product.find('sku-teste') 223 | 224 | product.update(name: 'Updated name', status: '2') 225 | 226 | # or save after changing the object 227 | 228 | product.name = 'Updated name' 229 | product.save 230 | ``` 231 | 232 | ### `delete` 233 | 234 | Delete a especific resource. 235 | 236 | ```rb 237 | Magento::Product.delete('sku-teste') 238 | 239 | # or 240 | product = Magento::Product.find('sku-teste') 241 | product.delete 242 | ``` 243 | 244 | ## Search Criteria 245 | 246 | They are methods used to assemble the search parameters 247 | 248 | All methods return an instance of the `Magento::Query` class. The request is only executed after calling method `all`. 249 | 250 | Example: 251 | 252 | ```rb 253 | customers = Magento::Customer 254 | .where(dob_gt: '1995-01-01') 255 | .order(:dob) 256 | .all 257 | 258 | # or 259 | 260 | query = Magento::Customer.where(dob_gt: '1995-01-01') 261 | 262 | query = query.order(:dob) if ordered_by_date_of_birth 263 | 264 | customers = query.all 265 | ``` 266 | 267 | ### Select fields: 268 | 269 | Example: 270 | ```rb 271 | Magento::Product.select(:id, :sku, :name).all 272 | 273 | Magento::Product 274 | .select( 275 | :id, 276 | :sku, 277 | :name, 278 | extension_attributes: :category_links 279 | ) 280 | .all 281 | 282 | Magento::Product 283 | .select( 284 | :id, 285 | :sku, 286 | :name, 287 | extension_attributes: [ 288 | :category_links, 289 | :website_ids 290 | ] 291 | ) 292 | .all 293 | 294 | Magento::Product 295 | .select( 296 | :id, 297 | :sku, 298 | :name, 299 | extension_attributes: [ 300 | { category_links: :category_id }, 301 | :website_ids 302 | ] 303 | ) 304 | .all 305 | ``` 306 | 307 | ### Filters 308 | 309 | Example: 310 | ```rb 311 | Magento::Product.where(visibility: 4).all 312 | Magento::Product.where(name_like: 'IPhone%').all 313 | Magento::Product.where(price_gt: 100).all 314 | 315 | # price > 10 AND price < 20 316 | Magento::Product.where(price_gt: 10) 317 | .where(price_lt: 20).all 318 | 319 | # price < 1 OR price > 100 320 | Magento::Product.where(price_lt: 1, price_gt: 100).all 321 | 322 | Magento::Order.where(status_in: [:canceled, :complete]).all 323 | 324 | ``` 325 | 326 | | Condition | Notes | 327 | | --------- | ----- | 328 | |eq | Equals. | 329 | |finset | A value within a set of values | 330 | |from | The beginning of a range. Must be used with to | 331 | |gt | Greater than | 332 | |gteq | Greater than or equal | 333 | |in | In. The value is an array | 334 | |like | Like. The value can contain the SQL wildcard characters when like is specified. | 335 | |lt | Less than | 336 | |lteq | Less than or equal | 337 | |moreq | More or equal | 338 | |neq | Not equal | 339 | |nfinset | A value that is not within a set of values | 340 | |nin | Not in. The value is an array | 341 | |notnull | Not null | 342 | |null | Null | 343 | |to | The end of a range. Must be used with from | 344 | 345 | 346 | ### Sort Order 347 | 348 | Example: 349 | ```rb 350 | Magento::Product.order(:sku).all 351 | Magento::Product.order(sku: :desc).all 352 | Magento::Product.order(status: :desc, name: :asc).all 353 | ``` 354 | 355 | ### Pagination: 356 | 357 | Example: 358 | ```rb 359 | # Set page and quantity per page 360 | Magento::Product 361 | .page(1) # Current page, Default is 1 362 | .page_size(25) # Default is 50 363 | .all 364 | 365 | # per is an alias to page_size 366 | Magento::Product.per(25).all 367 | ``` 368 | 369 | ## Record Collection 370 | 371 | The `all` method retorns a `Magento::RecordCollection` instance 372 | 373 | Example: 374 | ```rb 375 | products = Magento::Product.all 376 | 377 | products.first 378 | >> 379 | 380 | products[0] 381 | >> 382 | 383 | products.last 384 | >> 385 | 386 | products.map(&:sku) 387 | >> ["2100", "792", "836", "913", "964"] 388 | 389 | products.size 390 | >> 5 391 | 392 | products.current_page 393 | >> 1 394 | 395 | products.next_page 396 | >> 2 397 | 398 | products.last_page? 399 | >> false 400 | 401 | products.page_size 402 | >> 5 403 | 404 | products.total_count 405 | >> 307 406 | 407 | products.filter_groups 408 | >> []>] 409 | ``` 410 | 411 | All Methods: 412 | 413 | ```rb 414 | # Information about search criteria 415 | :current_page 416 | :next_page 417 | :last_page? 418 | :page_size 419 | :total_count 420 | :filter_groups 421 | 422 | # Iterating with the list of items 423 | :count 424 | :length 425 | :size 426 | 427 | :first 428 | :last 429 | :[] 430 | :find 431 | 432 | :each 433 | :each_with_index 434 | :sample 435 | 436 | :map 437 | :select 438 | :filter 439 | :reject 440 | :collect 441 | :take 442 | :take_while 443 | 444 | :sort 445 | :sort_by 446 | :reverse_each 447 | :reverse 448 | 449 | :all? 450 | :any? 451 | :none? 452 | :one? 453 | :empty? 454 | ``` 455 | 456 | ## Product 457 | 458 | ### Shurtcuts 459 | 460 | Shurtcut to get custom attribute value by custom attribute code in product 461 | 462 | Exemple: 463 | 464 | ```rb 465 | product.attr :description 466 | # it is the same as 467 | product.custom_attributes.find { |a| a.attribute_code == 'description' }&.value 468 | 469 | # or 470 | product.description 471 | ``` 472 | 473 | when the custom attribute does not exists: 474 | 475 | ```rb 476 | product.attr :special_price 477 | > nil 478 | 479 | product.special_price 480 | > NoMethodError: undefined method `special_price' for # 481 | ``` 482 | 483 | ```rb 484 | product.respond_to? :special_price 485 | > false 486 | 487 | product.respond_to? :description 488 | > true 489 | ``` 490 | 491 | Shurtcut to get product stock and stock quantity 492 | 493 | ```rb 494 | product = Magento::Product.find('sku') 495 | 496 | product.stock 497 | > 498 | 499 | product.stock_quantity 500 | > 22 501 | ``` 502 | 503 | ### Update stock 504 | 505 | Update product stock 506 | 507 | ```rb 508 | product = Magento::Product.find('sku') 509 | product.update_stock(qty: 12, is_in_stock: true) 510 | 511 | # or through the class method 512 | 513 | Magento::Product.update_stock('sku', id, { 514 | qty: 12, 515 | is_in_stock: true 516 | }) 517 | ``` 518 | 519 | > see all available attributes in: [Magento Rest Api Documentation](https://magento.redoc.ly/2.4.1-admin/tag/productsproductSkustockItemsitemId) 520 | 521 | 522 | ### Add media to product 523 | 524 | Create new gallery entry 525 | 526 | Example: 527 | ```rb 528 | product = Magento::Product.find('sku') 529 | 530 | image_params = { 531 | media_type: 'image', 532 | label: 'Image label', 533 | position: 1, 534 | content: { 535 | base64_encoded_data: '/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAA...', 536 | type: 'image/jpg', 537 | name: 'filename.jpg' 538 | }, 539 | types: ['image'] 540 | } 541 | 542 | product.add_media(image_params) 543 | 544 | # or through the class method 545 | 546 | Magento::Product.add_media('sku', image_params) 547 | ``` 548 | > see all available attributes in: [Magento Rest Api Documentation](https://magento.redoc.ly/2.3.6-admin/#operation/catalogProductAttributeMediaGalleryManagementV1CreatePost) 549 | 550 | 551 | you can also use the `Magento::Params::CreateImage` helper class 552 | 553 | ```rb 554 | params = Magento::Params::CreateImage.new( 555 | title: 'Image title', 556 | path: '/path/to/image.jpg', # or url 557 | position: 1, 558 | ).to_h 559 | 560 | product.add_media(params) 561 | ``` 562 | 563 | > see more about [Magento::Params::CreateImage](lib/magento/params/create_image.rb#L9) 564 | 565 | ### Remove media from product 566 | 567 | Example: 568 | ```rb 569 | product = Magento::Product.find('sku') 570 | 571 | product.add_media(media_id) 572 | 573 | # or through the class method 574 | 575 | Magento::Product.add_media('sku', media_id) 576 | ``` 577 | 578 | ### Add tier price to product 579 | 580 | Add `price` on product `sku` for specified `customer_group_id` 581 | 582 | The `quantity` params is the minimun amount to apply the price 583 | ```rb 584 | product = Magento::Product.find('sku') 585 | product.add_tier_price(3.99, quantity: 1, customer_group_id: :all) 586 | 587 | # or through the class method 588 | 589 | Magento::Product.add_tier_price('sku', 3.99, quantity: 1, customer_group_id: :all) 590 | ``` 591 | 592 | ### Remove tier price from product 593 | 594 | ```rb 595 | product = Magento::Product.find(1) 596 | product.remove_tier_price(quantity: 1, customer_group_id: :all) 597 | 598 | # or through the class method 599 | 600 | Magento::Product.remove_tier_price('sku', quantity: 1, customer_group_id: :all) 601 | ``` 602 | 603 | ### Create links to product 604 | 605 | Assign a product link to another product 606 | 607 | Example: 608 | ```rb 609 | product = Magento::Product.find('sku') 610 | 611 | link_param = { 612 | link_type: 'upsell', 613 | linked_product_sku: 'linked_product_sku', 614 | linked_product_type: 'simple', 615 | position: 1, 616 | sku: 'sku' 617 | } 618 | 619 | product.create_links([link_param]) 620 | 621 | # or through the class method 622 | 623 | Product.create_links('sku', [link_param]) 624 | ``` 625 | 626 | ### Remove link from product 627 | 628 | Example: 629 | ```rb 630 | product = Magento::Product.find('sku') 631 | 632 | product.remove_link(link_type: 'simple', linked_product_sku: 'linked_product_sku') 633 | 634 | # or through the class method 635 | 636 | Product.remove_link( 637 | 'sku', 638 | link_type: 'simple', 639 | linked_product_sku: 'linked_product_sku' 640 | ) 641 | ``` 642 | 643 | ## Order 644 | 645 | ### Invoice an Order 646 | 647 | Example: 648 | ```rb 649 | Magento::Order.invoice(order_id) 650 | >> 25 # return incoice id 651 | 652 | # or from instance 653 | 654 | order = Magento::Order.find(order_id) 655 | 656 | invoice_id = order.invoice 657 | 658 | # you can pass parameters too 659 | 660 | invoice_id = order.invoice( 661 | capture: false, 662 | appendComment: true, 663 | items: [{ order_item_id: 123, qty: 1 }], # pass items to partial invoice 664 | comment: { 665 | extension_attributes: { }, 666 | comment: "string", 667 | is_visible_on_front: 0 668 | }, 669 | notify: true 670 | ) 671 | ``` 672 | 673 | [Complete Invoice Documentation](https://magento.redoc.ly/2.4-admin/tag/orderorderIdinvoice#operation/salesInvoiceOrderV1ExecutePost) 674 | 675 | ### Create offline refund for order 676 | 677 | Example: 678 | ```rb 679 | Magento::Order.refund(order_id) 680 | >> 12 # return refund id 681 | 682 | # or from instance 683 | 684 | order = Magento::Order.find(order_id) 685 | 686 | order.refund 687 | 688 | # you can pass parameters too 689 | 690 | order.refund( 691 | items: [ 692 | { 693 | extension_attributes: {}, 694 | order_item_id: 0, 695 | qty: 0 696 | } 697 | ], 698 | notify: true, 699 | appendComment: true, 700 | comment: { 701 | extension_attributes: {}, 702 | comment: string, 703 | is_visible_on_front: 0 704 | }, 705 | arguments: { 706 | shipping_amount: 0, 707 | adjustment_positive: 0, 708 | adjustment_negative: 0, 709 | extension_attributes: { 710 | return_to_stock_items: [0] 711 | } 712 | } 713 | ) 714 | ``` 715 | 716 | [Complete Refund Documentation](https://magento.redoc.ly/2.4-admin/tag/invoicescomments#operation/salesRefundOrderV1ExecutePost) 717 | 718 | ### Creates new Shipment for given Order. 719 | 720 | Example: 721 | ```rb 722 | Magento::Order.ship(order_id) 723 | >> 25 # return shipment id 724 | 725 | # or from instance 726 | 727 | order = Magento::Order.find(order_id) 728 | 729 | order.ship 730 | 731 | # you can pass parameters too 732 | 733 | order.ship( 734 | capture: false, 735 | appendComment: true, 736 | items: [{ order_item_id: 123, qty: 1 }], # pass items to partial shipment 737 | tracks: [ 738 | { 739 | extension_attributes: { }, 740 | track_number: "string", 741 | title: "string", 742 | carrier_code: "string" 743 | } 744 | ] 745 | notify: true 746 | ) 747 | ``` 748 | 749 | [Complete Shipment Documentation](https://magento.redoc.ly/2.4-admin/tag/orderorderIdship#operation/salesShipOrderV1ExecutePost) 750 | 751 | 752 | ### Cancel an Order 753 | 754 | Example: 755 | ```rb 756 | order = Magento::Order.find(order_id) 757 | 758 | order.cancel # or 759 | 760 | Magento::Order.cancel(order_id) 761 | ``` 762 | 763 | ### Add a comment on given Order 764 | 765 | Example: 766 | ```rb 767 | order = Magento::Order.find(order_id) 768 | 769 | order.add_comment( 770 | 'comment', 771 | is_customer_notified: 0, 772 | is_visible_on_front: 1 773 | ) 774 | 775 | # or 776 | 777 | Magento::Order.add_comment( 778 | order_id, 779 | 'comment', 780 | is_customer_notified: 0, 781 | is_visible_on_front: 1 782 | ) 783 | ``` 784 | 785 | ## Invoice 786 | 787 | ### Create refund for invoice 788 | 789 | Example: 790 | ```rb 791 | Magento::Invoice.refund(invoice_id) 792 | >> 12 # return refund id 793 | 794 | # or from instance 795 | 796 | invoice = Magento::Invoice.find(invoice_id) 797 | 798 | refund_id = invoice.refund 799 | 800 | # you can pass parameters too 801 | 802 | invoice.refund( 803 | items: [ 804 | { 805 | extension_attributes: {}, 806 | order_item_id: 0, 807 | qty: 0 808 | } 809 | ], 810 | isOnline: true, 811 | notify: true, 812 | appendComment: true, 813 | comment: { 814 | extension_attributes: {}, 815 | comment: string, 816 | is_visible_on_front: 0 817 | }, 818 | arguments: { 819 | shipping_amount: 0, 820 | adjustment_positive: 0, 821 | adjustment_negative: 0, 822 | extension_attributes: { 823 | return_to_stock_items: [0] 824 | } 825 | } 826 | ) 827 | ``` 828 | 829 | [Complete Refund Documentation](https://magento.redoc.ly/2.4-admin/tag/invoicescomments#operation/salesRefundInvoiceV1ExecutePost) 830 | 831 | ### Capture an invoice 832 | 833 | Example: 834 | ```rb 835 | invoice = Magento::Invoice.find(invoice_id) 836 | invoice.capture 837 | 838 | # or through the class method 839 | Magento::Invoice.capture(invoice_id) 840 | ``` 841 | 842 | ### Void an invoice 843 | 844 | Example: 845 | ```rb 846 | invoice = Magento::Invoice.find(invoice_id) 847 | invoice.void 848 | 849 | # or through the class method 850 | Magento::Invoice.void(invoice_id) 851 | ``` 852 | 853 | ### Send invoice email 854 | 855 | Example: 856 | ```rb 857 | invoice = Magento::Invoice.find(invoice_id) 858 | invoice.send_email 859 | 860 | # or through the class method 861 | Magento::Invoice.send_email(invoice_id) 862 | ``` 863 | 864 | ### Get invoice comments 865 | 866 | Example: 867 | ```rb 868 | Magento::Invoice.comments(invoice_id).all 869 | Magento::Invoice.comments(invoice_id).where(created_at_gt: Date.today.prev_day).all 870 | ``` 871 | 872 | ## Sales Rules 873 | 874 | ### Generate Sales Rules and Coupons 875 | 876 | ```rb 877 | rule = Magento::SalesRule.create( 878 | name: 'Discount name', 879 | website_ids: [1], 880 | customer_group_ids: [0,1,2,3], 881 | uses_per_customer: 1, 882 | is_active: true, 883 | stop_rules_processing: true, 884 | is_advanced: false, 885 | sort_order: 0, 886 | discount_amount: 100, 887 | discount_step: 1, 888 | apply_to_shipping: true, 889 | times_used: 0, 890 | is_rss: true, 891 | coupon_type: 'specific', 892 | use_auto_generation: true, 893 | uses_per_coupon: 1 894 | ) 895 | 896 | rule.generate_coupon(quantity: 1, length: 10) 897 | ``` 898 | 899 | Generate by class method 900 | ```rb 901 | Magento::SalesRule.generate_coupon( 902 | couponSpec: { 903 | rule_id: 7, 904 | quantity: 1, 905 | length: 10 906 | } 907 | ) 908 | ``` 909 | > see all params in: 910 | [Magento docs Coupon](https://magento.redoc.ly/2.3.5-admin/tag/couponsgenerate#operation/salesRuleCouponManagementV1GeneratePost) and 911 | [Magento docs SalesRules](https://magento.redoc.ly/2.3.5-admin/tag/salesRules#operation/salesRuleRuleRepositoryV1SavePost) 912 | 913 | 914 | ## Customer 915 | 916 | ### Get customer by token 917 | ```rb 918 | Magento::Customer.find_by_token('user_token') 919 | ``` 920 | 921 | ### Customer login 922 | Exemple: 923 | ```rb 924 | Magento::Customer.login('username', 'password') 925 | 926 | >> 'aj8oer4eQi44FrghgfhVdbBKN' #return user token 927 | ``` 928 | 929 | ### Customer reset password 930 | Exemple: 931 | ```rb 932 | Magento::Customer.reset_password( 933 | email: 'user_email', 934 | reset_token: 'user_reset_token', 935 | new_password: 'user_new_password' 936 | ) 937 | 938 | >> true # return true on success 939 | ``` 940 | 941 | ## Guest Cart 942 | 943 | ### Payment information 944 | Set payment information to finish the order 945 | 946 | Example: 947 | ```rb 948 | cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 949 | 950 | # or use "build" to not request information from the magento API 951 | cart = Magento::GuestCart.build( 952 | cart_id: 'aj8oUtY1Qi44Fror6UWVN7ftX1idbBKN' 953 | ) 954 | 955 | cart.payment_information( 956 | email: 'customer@gmail.com', 957 | payment: { method: 'cashondelivery' } 958 | ) 959 | 960 | >> "234575" # return the order id 961 | ``` 962 | 963 | ### Add coupon to guest cart 964 | 965 | Example: 966 | ```rb 967 | cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 968 | 969 | cart.add_coupon('COAU4HXE0I') 970 | # You can also use the class method 971 | Magento::GuestCart.add_coupon('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7', 'COAU4HXE0I') 972 | 973 | >> true # return true on success 974 | ``` 975 | 976 | ### Remove coupon from guest cart 977 | 978 | Example: 979 | ```rb 980 | cart = Magento::GuestCart.find('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 981 | 982 | cart.delete_coupon() 983 | # You can also use the class method 984 | Magento::GuestCart.delete_coupon('gXsepZcgJbY8RCJXgGioKOO9iBCR20r7') 985 | 986 | >> true # return true on success 987 | ``` 988 | 989 | ## Inventory 990 | 991 | ### Check whether a product is salable 992 | 993 | Example: 994 | ```rb 995 | Inventory.get_product_salable_quantity(sku: '4321', stock_id: 1) 996 | >> 1 997 | ``` 998 | 999 | ### Check whether a product is salable for a specified quantity 1000 | 1001 | Example: 1002 | ```rb 1003 | Inventory.is_product_salable_for_requested_qty( 1004 | sku: '4321', 1005 | stock_id: 1, 1006 | requested_qty: 2 1007 | ) 1008 | >> OpenStruct { 1009 | :salable => false, 1010 | :errors => [ 1011 | [0] { 1012 | "code" => "back_order-disabled", 1013 | "message" => "Backorders are disabled" 1014 | }, 1015 | ... 1016 | ] 1017 | } 1018 | ``` 1019 | 1020 | ## **Helper classes** 1021 | 1022 | ## Create product params 1023 | 1024 | ```rb 1025 | params = Magento::Params::CreateProduct.new( 1026 | sku: '556-teste-builder', 1027 | name: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 1028 | description: 'Descrição do produto', 1029 | brand: 'Coca-Cola', 1030 | price: 4.99, 1031 | special_price: 3.49, 1032 | quantity: 2, 1033 | weight: 0.3, 1034 | attribute_set_id: 4, 1035 | images: [ 1036 | *Magento::Params::CreateImage.new( 1037 | path: 'https://urltoimage.com/image.jpg', 1038 | title: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 1039 | position: 1, 1040 | main: true 1041 | ).variants, 1042 | Magento::Params::CreateImage.new( 1043 | path: '/path/to/image.jpg', 1044 | title: 'REFRIGERANTE PET COCA-COLA 1,5L ORIGINAL', 1045 | position: 2 1046 | ) 1047 | ] 1048 | ) 1049 | 1050 | Magento::Product.create(params.to_h) 1051 | ``` 1052 | 1053 | ## Create product image params 1054 | 1055 | Helper class to create product image params. 1056 | 1057 | before generating the hash, the following image treatments are performed: 1058 | - resize image 1059 | - remove alpha 1060 | - leaves square 1061 | - convert image to jpg 1062 | 1063 | Example: 1064 | ```rb 1065 | params = Magento::Params::CreateImage.new( 1066 | title: 'Image title', 1067 | path: '/path/to/image.jpg', # or url 1068 | position: 1, 1069 | size: 'small', # options: 'large'(defaut), 'medium' and 'small', 1070 | disabled: true, # default is false, 1071 | main: true, # default is false, 1072 | ).to_h 1073 | 1074 | Magento::Product.add_media('sku', params) 1075 | ``` 1076 | 1077 | The resize defaut confiruration is: 1078 | 1079 | ```rb 1080 | Magento.configure do |config| 1081 | config.product_image.small_size = '200x200>' 1082 | config.product_image.medium_size = '400x400>' 1083 | config.product_image.large_size = '800x800>' 1084 | end 1085 | ``` 1086 | 1087 | ## Import products from csv file 1088 | 1089 | _TODO: exemple to [Magento::Import.from_csv](lib/magento/import.rb#L8)_ 1090 | 1091 | _TODO: exemple to [Magento::Import.get_csv_template](lib/magento/import.rb#L14)_ 1092 | 1093 | 1094 | ___ 1095 | 1096 | 1097 | ## **TODO:** 1098 | 1099 | ### Search products 1100 | ```rb 1101 | Magento::Product.search('tshort') 1102 | ``` 1103 | 1104 | ### Last result 1105 | ```rb 1106 | Magento::Product.last 1107 | >> 1108 | 1109 | Magento::Product.where(name_like: 'some name%').last 1110 | >> 1111 | ``` 1112 | 1113 | ### Tests 1114 | 1115 | ___ 1116 | 1117 | ## Development 1118 | 1119 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 1120 | 1121 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 1122 | 1123 | ## Contributing 1124 | 1125 | Bug reports and pull requests are welcome on GitHub at https://github.com/WallasFaria/magento_ruby. 1126 | --------------------------------------------------------------------------------