apress-moysklad 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 260b8a3d6b7e1cd7fafa3746411cfa2491d62beb
4
+ data.tar.gz: 9a1fba4480cce2812ba1e6287d1d7c2c95979286
5
+ SHA512:
6
+ metadata.gz: b5dcce2294b58b4bfe0c860f40d30355699a61d884bc3eb43c241b4f6e511c9c30f7fce59a596801ffe49350432699bc2b7e7f28e099049e631b33882b09041b
7
+ data.tar.gz: 46ffe8b1e5b59df6a6178516f5badcd05c9073be42e6be2acee9459135a84e1f143448177daea93346a8b5f46e91bf83ec1c0a4bf79098f98c5dbc4a1c093b75
@@ -0,0 +1,30 @@
1
+ build:
2
+ test:
3
+ image: abakpress/dind-testing
4
+ pull: true
5
+ privileged: true
6
+ volumes:
7
+ - /home/data/drone/images:/images
8
+ - /home/data/drone/gems:/bundle
9
+ environment:
10
+ - COMPOSE_FILE_EXT=drone
11
+ - RUBY_IMAGE_TAG=2.2-latest
12
+ commands:
13
+ - prepare-build
14
+
15
+ - fetch-images
16
+ --image abakpress/ruby-app:$RUBY_IMAGE_TAG
17
+
18
+ - dip provision
19
+ - dip rspec
20
+
21
+ release:
22
+ image: abakpress/gem-publication:latest
23
+ pull: true
24
+ when:
25
+ event: push
26
+ branch: master
27
+ volumes:
28
+ - /home/data/drone/rubygems:/root/.gem
29
+ commands:
30
+ - release-gem --public
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /Gemfile.lock
3
+ /coverage/
4
+ /doc/
5
+ /pkg/
6
+ /tmp/
7
+ *.bundle
8
+ *.so
9
+ *.o
10
+ *.a
11
+ mkmf.log
12
+ /.rvmrc
13
+ /.idea/
14
+ gemfiles/
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --format progress
3
+ --order random
@@ -0,0 +1,6 @@
1
+ # v0.1.0
2
+
3
+ * 2018-09-19 [4bc01cf](../../commit/4bc01cf) - __(Korotaev Danil)__ feat: moving from kirby-moysklad
4
+ https://jira.railsc.ru/browse/GOODS-1495
5
+
6
+ * 2018-09-18 [1f7be49](../../commit/1f7be49) - __(Mamedaliev Kirill)__ Initial commit
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,11 @@
1
+ # Apress::Moysklad
2
+
3
+ Библиотека для работы с онлайн-сервисом [МойСклад](https://online.moysklad.ru/api/remap/1.1/doc/index.html)
4
+
5
+ Получение ассортимента акаунта:
6
+
7
+ ```ruby
8
+ Apress::Moysklad::Readers::Assortment.new(login: login, password: password).each_row do |row|
9
+ ...
10
+ end
11
+ ```
@@ -0,0 +1,7 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+
4
+ require 'rspec/core/rake_task'
5
+
6
+ # setup `spec` task
7
+ RSpec::Core::RakeTask.new(:spec)
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include? lib
3
+
4
+ require 'apress/moysklad/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = 'apress-moysklad'
8
+ gem.version = Apress::Moysklad::VERSION
9
+ gem.authors = ['Korotaev Danil']
10
+ gem.email = %w(korotaev.danil@gmail.com)
11
+ gem.summary = 'Tools for synchronization with MoySklad online-service'
12
+ gem.homepage = 'https://github.com/abak-press/apress-moysklad'
13
+
14
+ gem.files = `git ls-files -z`.split("\x0")
15
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
16
+ gem.require_path = "lib"
17
+
18
+ gem.add_runtime_dependency 'oj'
19
+
20
+ gem.add_development_dependency 'bundler', '~> 1.6'
21
+ gem.add_development_dependency 'rake', '< 11.0' # https://github.com/lsegal/yard/issues/947
22
+ gem.add_development_dependency 'rspec', '>= 3.5'
23
+ gem.add_development_dependency 'rspec-its'
24
+ gem.add_development_dependency 'rspec-collection_matchers'
25
+ gem.add_development_dependency 'timecop'
26
+ gem.add_development_dependency 'webmock', '< 2.3'
27
+ gem.add_development_dependency 'vcr'
28
+
29
+ # test coverage tools
30
+ gem.add_development_dependency 'simplecov', '~> 0.10.0'
31
+ end
data/dip.yml ADDED
@@ -0,0 +1,41 @@
1
+ version: '2'
2
+
3
+ environment:
4
+ DOCKER_RUBY_VERSION: 2.2
5
+ RUBY_IMAGE_TAG: 2.2-latest
6
+ COMPOSE_FILE_EXT: development
7
+ RAILS_ENV: test
8
+
9
+ compose:
10
+ files:
11
+ - docker-compose.yml
12
+ - docker-compose.${COMPOSE_FILE_EXT}.yml
13
+
14
+ interaction:
15
+ sh:
16
+ service: app
17
+
18
+ irb:
19
+ service: app
20
+ command: irb
21
+
22
+ bundle:
23
+ service: app
24
+ command: bundle
25
+
26
+ rake:
27
+ service: app
28
+ command: bundle exec rake
29
+
30
+ rspec:
31
+ service: app
32
+ command: bundle exec rspec
33
+
34
+ clean:
35
+ service: app
36
+ command: rm -f Gemfile.lock gemfiles/*.gemfile.*
37
+
38
+ provision:
39
+ - docker volume create --name bundler_data
40
+ - dip clean
41
+ - dip bundle install
@@ -0,0 +1,13 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ volumes:
6
+ - .:/app
7
+ - ../:/localgems
8
+ - bundler-data:/bundle
9
+
10
+ volumes:
11
+ bundler-data:
12
+ external:
13
+ name: bundler_data
@@ -0,0 +1,7 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ volumes:
6
+ - .:/app
7
+ - /bundle:/bundle
@@ -0,0 +1,9 @@
1
+ version: '2'
2
+
3
+ services:
4
+ app:
5
+ image: abakpress/ruby-app:$RUBY_IMAGE_TAG
6
+ environment:
7
+ - BUNDLE_PATH=/bundle/$DOCKER_RUBY_VERSION
8
+ - BUNDLE_CONFIG=/app/.bundle/config
9
+ command: bash
@@ -0,0 +1,9 @@
1
+ require 'apress/moysklad/version'
2
+
3
+ module Apress
4
+ module Moysklad
5
+ autoload :Api, 'apress/moysklad/api'
6
+ autoload :Presenters, 'apress/moysklad/presenters'
7
+ autoload :Readers, 'apress/moysklad/readers'
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ module Apress
2
+ module Moysklad
3
+ module Api
4
+ autoload :Client, 'apress/moysklad/api/client'
5
+ autoload :Error, 'apress/moysklad/api/error'
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,72 @@
1
+ require 'net/http'
2
+ require 'openssl'
3
+ require 'uri'
4
+
5
+ require 'oj'
6
+
7
+ module Apress
8
+ module Moysklad
9
+ module Api
10
+ # Клиент для взаимодействия с API МойСклад
11
+ #
12
+ # @example
13
+ # client = Apress::Moysklad::Api::Client.new('login', 'password')
14
+ #
15
+ # client.get(:assortment, limit: 2)
16
+ # => {:context=>...}
17
+ class Client
18
+ API_URL = 'https://online.moysklad.ru/api/remap'.freeze
19
+ API_VERSION = '1.1'.freeze
20
+
21
+ TIMEOUT = 60 # seconds
22
+
23
+ attr_reader :login, :password
24
+
25
+ def initialize(login, password)
26
+ @login = login
27
+ @password = password
28
+ end
29
+
30
+ def get(entity, params = {})
31
+ uri = api_uri(entity)
32
+ uri.query = URI.encode_www_form(params) unless params.empty?
33
+
34
+ req = Net::HTTP::Get.new(uri)
35
+ req.basic_auth login, password
36
+
37
+ res = Net::HTTP.start(uri.hostname, uri.port, http_options) do |http|
38
+ http.request(req)
39
+ end
40
+
41
+ parse_response(res)
42
+ end
43
+
44
+ private
45
+
46
+ def api_uri(entity)
47
+ URI "#{API_URL}/#{API_VERSION}/entity/#{entity}"
48
+ end
49
+
50
+ def http_options
51
+ {
52
+ open_timeout: TIMEOUT,
53
+ read_timeout: TIMEOUT,
54
+ use_ssl: true
55
+ }
56
+ end
57
+
58
+ def parse_response(res)
59
+ Oj.load(res.body, symbol_keys: true, mode: :compat).tap do |data|
60
+ if data.key? :errors
61
+ err = data[:errors].first
62
+
63
+ raise Api::Error.new(err[:error], err[:code])
64
+ end
65
+
66
+ raise Api::Error.new(res.msg, res.code) unless res.is_a? Net::HTTPOK
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,18 @@
1
+ module Apress
2
+ module Moysklad
3
+ module Api
4
+ # Ошибка при взаимодействии с API МойСклад
5
+ class Error < StandardError
6
+ attr_reader :code
7
+
8
+ def initialize(msg, code = nil)
9
+ @code = code.to_i
10
+
11
+ message = code ? "#{code} - #{msg}" : msg
12
+
13
+ super message.force_encoding('UTF-8')
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ module Apress
2
+ module Moysklad
3
+ module Presenters
4
+ autoload :Assortment, 'apress/moysklad/presenters/assortment'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,95 @@
1
+ module Apress
2
+ module Moysklad
3
+ module Presenters
4
+ # Презентер для объектов типа - Ассортимент в МойСклад
5
+ #
6
+ # @example
7
+ # client = Apress::Moysklad::Api::Client.new('login', 'password')
8
+ # presenter = Apress::Moysklad::Presenters::Assortment.new(client)
9
+ #
10
+ # row = client.get(:assortment)[:rows].first
11
+ # presenter.expose(row)
12
+ # => {:__line__=>1, :__column__=>1, :id=>"1a3cc760-eaf6-11e7-6b01-4b1d001d43a0", ...}
13
+ class Assortment
14
+ ATTRIBUTES = [
15
+ :id,
16
+ :code,
17
+ :accountId,
18
+ :externalCode,
19
+
20
+ :article,
21
+ :name,
22
+ :description,
23
+
24
+ :minPrice,
25
+ :pathName,
26
+ :version,
27
+
28
+ :stock,
29
+ :reserve,
30
+ :inTransit,
31
+ :quantity,
32
+
33
+ :archived,
34
+ :updated,
35
+
36
+ :modificationsCount,
37
+ :weight,
38
+ :volume,
39
+ :vat,
40
+ :effectiveVat,
41
+
42
+ meta: [:type].freeze,
43
+ image: [:title, :filename, :size, :updated, meta: [:href, :mediaType].freeze].freeze,
44
+
45
+ buyPrice: [:value].freeze,
46
+ salePrices: [:value, :priceType].freeze
47
+ ].freeze
48
+
49
+ attr_reader :client
50
+
51
+ def initialize(client)
52
+ @client = client
53
+ @counter = 0
54
+ end
55
+
56
+ def expose(row)
57
+ record = {
58
+ __line__: @counter += 1,
59
+ __column__: 1
60
+ }
61
+
62
+ record.merge! filter(row)
63
+ auth_to_link! record[:image][:meta][:href] if record.key? :image
64
+
65
+ record
66
+ end
67
+
68
+ private
69
+
70
+ def auth_to_link!(link)
71
+ link.sub!(%r{://}, "://#{client.login}:#{client.password}@")
72
+ end
73
+
74
+ def filter(row, attrs = ATTRIBUTES)
75
+ attrs.each_with_object({}) do |key, result|
76
+ if key.is_a? Hash
77
+ key.each do |hash_key, hash_attrs|
78
+ next unless row.key? hash_key
79
+
80
+ result[hash_key] =
81
+ if row[hash_key].is_a? Array
82
+ row[hash_key].map { |item| filter(item, hash_attrs) }
83
+ else
84
+ filter(row[hash_key], hash_attrs)
85
+ end
86
+ end
87
+ elsif row.key? key
88
+ result[key] = row[key]
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ module Apress
2
+ module Moysklad
3
+ module Readers
4
+ autoload :Assortment, 'apress/moysklad/readers/assortment'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ require 'apress/moysklad/api'
2
+ require 'apress/moysklad/presenters'
3
+ require 'timeout'
4
+
5
+ module Apress
6
+ module Moysklad
7
+ module Readers
8
+ # Сервис загрузки ассортимента из акаунта МойСклад
9
+ #
10
+ # @example
11
+ # reader = Apress::Moysklad::Readers::Assortment.new('login', 'password').each_row { |row| puts row }
12
+ class Assortment
13
+ RETRY_ATTEMPTS = 5
14
+ RETRY_CODES = [500, 502, 503, 504, 1999].freeze
15
+
16
+ ROWS_BATCH = 100
17
+
18
+ class << self
19
+ def allowed_options
20
+ %i[login password]
21
+ end
22
+ end
23
+
24
+ attr_reader :client
25
+
26
+ def initialize(options)
27
+ login = options.fetch(:login)
28
+ password = options.fetch(:password)
29
+
30
+ @client = Api::Client.new(login, password)
31
+ end
32
+
33
+ def each_row
34
+ offset = 0
35
+ presenter = Presenters::Assortment.new(client)
36
+
37
+ loop do
38
+ data = with_retry do
39
+ client.get(:assortment, limit: ROWS_BATCH, offset: offset, scope: :product, archived: :All)
40
+ end
41
+
42
+ check_rows_size! data, offset
43
+
44
+ data[:rows].each { |row| yield presenter.expose(row) }
45
+
46
+ break if (offset += ROWS_BATCH) >= data[:meta][:size]
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def check_rows_size!(data, offset)
53
+ actual_size = data[:rows].size
54
+ expected_size = [data[:meta][:size] - offset, ROWS_BATCH].min
55
+
56
+ raise "Invalid rows size, expect #{expected_size}, got #{actual_size}" if actual_size != expected_size
57
+ end
58
+
59
+ def with_retry(attempts = RETRY_ATTEMPTS)
60
+ yield
61
+ rescue Api::Error, Timeout::Error => err
62
+ raise if err.is_a?(Api::Error) && !RETRY_CODES.include?(err.code)
63
+
64
+ (attempts -= 1) > 0 ? retry : raise
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end