apress-moysklad 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.drone.yml +30 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +3 -0
- data/README.md +11 -0
- data/Rakefile +7 -0
- data/apress-moysklad.gemspec +31 -0
- data/dip.yml +41 -0
- data/docker-compose.development.yml +13 -0
- data/docker-compose.drone.yml +7 -0
- data/docker-compose.yml +9 -0
- data/lib/apress/moysklad.rb +9 -0
- data/lib/apress/moysklad/api.rb +8 -0
- data/lib/apress/moysklad/api/client.rb +72 -0
- data/lib/apress/moysklad/api/error.rb +18 -0
- data/lib/apress/moysklad/presenters.rb +7 -0
- data/lib/apress/moysklad/presenters/assortment.rb +95 -0
- data/lib/apress/moysklad/readers.rb +7 -0
- data/lib/apress/moysklad/readers/assortment.rb +69 -0
- data/lib/apress/moysklad/version.rb +5 -0
- data/spec/apress/moysklad/api/client_spec.rb +70 -0
- data/spec/apress/moysklad/api/error_spec.rb +17 -0
- data/spec/apress/moysklad/presenters/assortment_spec.rb +71 -0
- data/spec/apress/moysklad/readers/assortment_spec.rb +53 -0
- data/spec/fixtures/assortment.json +188 -0
- data/spec/fixtures/get_assortment.yml +468 -0
- data/spec/fixtures/get_assortment_unauthorized.yml +54 -0
- data/spec/fixtures/get_assortment_with_invalid_params.yml +50 -0
- data/spec/fixtures/get_assortment_with_limit_and_offset.yml +212 -0
- data/spec/fixtures/get_invalid_entity.yml +47 -0
- data/spec/fixtures/read_assortment.yml +423 -0
- data/spec/spec_helper.rb +23 -0
- metadata +228 -0
checksums.yaml
ADDED
@@ -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
|
data/.drone.yml
ADDED
@@ -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
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -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
|
+
```
|
data/Rakefile
ADDED
@@ -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
|
data/docker-compose.yml
ADDED
@@ -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,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,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
|