belpost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c53d02403855e383fbb7d309ddeeeec66b2065303321cdde544d0e4fa6aba93e
4
+ data.tar.gz: aef5e4c2911ae80fca64dff3fb347cbf270ca66ea82da3905d3446a65efd543e
5
+ SHA512:
6
+ metadata.gz: cfef1ca328d78d3ff55335a2271ba505e02ef2a95af81924a43acc736fc1a09828197a5075bc501d219ed60847dcf606eea082a68d7617769a5cbc6a94395ff4
7
+ data.tar.gz: ffe48fcfa53026d756eccc552db0cdac37c17aed4b14489852d51a51d2a6ae97e9f931bbd7babcd2842232003f807152c550e315f7c9bee34446955994cb05f4
data/.env.example ADDED
File without changes
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
data/README.md ADDED
@@ -0,0 +1,334 @@
1
+ # Belpost
2
+
3
+ Клиент для работы с Belpochta API (Белпочта).
4
+
5
+ ## Установка
6
+
7
+ Добавьте эту строку в Gemfile вашего приложения:
8
+
9
+ ```ruby
10
+ gem 'belpost'
11
+ ```
12
+
13
+ И выполните:
14
+
15
+ ```bash
16
+ $ bundle install
17
+ ```
18
+
19
+ Или установите самостоятельно:
20
+
21
+ ```bash
22
+ $ gem install belpost
23
+ ```
24
+
25
+ ## Настройка
26
+
27
+ Настройте клиент для работы с API Белпочты:
28
+
29
+ ```ruby
30
+ require 'belpost'
31
+
32
+ Belpost.configure do |config|
33
+ config.jwt_token = 'ваш_jwt_токен_от_Белпочты'
34
+ config.base_url = 'https://api.belpost.by'
35
+ config.timeout = 30 # Таймаут в секундах (по умолчанию 10)
36
+ end
37
+ ```
38
+
39
+ Вы также можете использовать переменные окружения:
40
+
41
+ ```
42
+ BELPOST_JWT_TOKEN=ваш_jwt_токен_от_Белпочты
43
+ BELPOST_BASE_URL=https://api.belpost.by
44
+ BELPOST_TIMEOUT=30
45
+ ```
46
+
47
+ ## Использование
48
+
49
+ ### Создание посылки
50
+
51
+ #### Базовый пример
52
+
53
+ ```ruby
54
+ client = Belpost::Client.new
55
+
56
+ parcel_data = {
57
+ parcel: {
58
+ type: "package",
59
+ attachment_type: "products",
60
+ measures: {
61
+ weight: 12
62
+ },
63
+ departure: {
64
+ country: "BY",
65
+ place: "post_office"
66
+ },
67
+ arrival: {
68
+ country: "BY",
69
+ place: "post_office"
70
+ }
71
+ },
72
+ addons: {
73
+ declared_value: {
74
+ currency: "BYN",
75
+ value: 100
76
+ },
77
+ cash_on_delivery: {
78
+ currency: "BYN",
79
+ value: 10
80
+ }
81
+ },
82
+ sender: {
83
+ type: "legal_person",
84
+ info: {
85
+ organization_name: "ООО \"Компания\"",
86
+ taxpayer_number: "123456789",
87
+ IBAN: "BY26BAPB30123418400100000000",
88
+ BIC: "BAPBBY2X",
89
+ bank: "ОАО 'БЕЛАГРОПРОМБАНК'"
90
+ },
91
+ location: {
92
+ code: "225212",
93
+ region: "Брестская",
94
+ district: "Березовский",
95
+ locality: {
96
+ type: "город",
97
+ name: "Береза"
98
+ },
99
+ road: {
100
+ type: "улица",
101
+ name: "Ленина"
102
+ },
103
+ building: "1",
104
+ housing: "",
105
+ apartment: ""
106
+ },
107
+ email: "test@example.com",
108
+ phone: "375291234567"
109
+ },
110
+ recipient: {
111
+ type: "natural_person",
112
+ info: {
113
+ first_name: "Иван",
114
+ second_name: "Иванович",
115
+ last_name: "Иванов"
116
+ },
117
+ location: {
118
+ code: "231365",
119
+ region: "Гродненская",
120
+ district: "Ивьевский",
121
+ locality: {
122
+ type: "деревня",
123
+ name: "Дуды"
124
+ },
125
+ road: {
126
+ type: "улица",
127
+ name: "Центральная"
128
+ },
129
+ building: "1",
130
+ housing: "",
131
+ apartment: ""
132
+ },
133
+ email: "",
134
+ phone: "375291234567"
135
+ }
136
+ }
137
+
138
+ response = client.create_parcel(parcel_data)
139
+ puts "Трекинг код: #{response["data"]["parcel"]["s10code"]}"
140
+ ```
141
+
142
+ #### Использование ParcelBuilder
143
+
144
+ ```ruby
145
+ client = Belpost::Client.new
146
+
147
+ # Создание внутренней посылки
148
+ parcel_data = Belpost::Models::ParcelBuilder.new
149
+ .with_type("package")
150
+ .with_attachment_type("products")
151
+ .with_weight(1500) # вес в граммах
152
+ .with_dimensions(300, 200, 100) # длина, ширина, высота в мм
153
+ .to_country("BY")
154
+ .with_declared_value(100)
155
+ .with_cash_on_delivery(50)
156
+ .add_service(:simple_notification)
157
+ .add_service(:email_notification)
158
+ .from_legal_person("ООО \"Компания\"")
159
+ .with_sender_details(
160
+ taxpayer_number: "123456789",
161
+ bank: "ОАО 'БЕЛАГРОПРОМБАНК'",
162
+ iban: "BY26BAPB30123418400100000000",
163
+ bic: "BAPBBY2X"
164
+ )
165
+ .with_sender_location(
166
+ postal_code: "225212",
167
+ region: "Брестская",
168
+ district: "Березовский",
169
+ locality_type: "город",
170
+ locality_name: "Береза",
171
+ road_type: "улица",
172
+ road_name: "Ленина",
173
+ building: "1"
174
+ )
175
+ .with_sender_contact(
176
+ email: "test@example.com",
177
+ phone: "375291234567"
178
+ )
179
+ .to_natural_person(
180
+ first_name: "Иван",
181
+ last_name: "Иванов",
182
+ second_name: "Иванович"
183
+ )
184
+ .with_recipient_location(
185
+ postal_code: "231365",
186
+ region: "Гродненская",
187
+ district: "Ивьевский",
188
+ locality_type: "деревня",
189
+ locality_name: "Дуды",
190
+ road_type: "улица",
191
+ road_name: "Центральная",
192
+ building: "1"
193
+ )
194
+ .with_recipient_contact(
195
+ phone: "375291234567"
196
+ )
197
+ .build
198
+
199
+ response = client.create_parcel(parcel_data)
200
+ puts "Трекинг код: #{response["data"]["parcel"]["s10code"]}"
201
+ ```
202
+
203
+ #### Создание международной посылки с таможенной декларацией
204
+
205
+ ```ruby
206
+ client = Belpost::Client.new
207
+
208
+ # Создание таможенной декларации
209
+ customs_declaration = Belpost::Models::CustomsDeclaration.new
210
+ customs_declaration.set_category("gift")
211
+ customs_declaration.set_price("USD", 50)
212
+ customs_declaration.add_item(
213
+ {
214
+ name: "Книга",
215
+ local: "Книга",
216
+ unit: {
217
+ local: "ШТ",
218
+ en: "PCS"
219
+ },
220
+ count: 1,
221
+ weight: 500,
222
+ price: {
223
+ currency: "USD",
224
+ value: 50
225
+ },
226
+ country: "BY"
227
+ }
228
+ )
229
+
230
+ # Создание международной посылки
231
+ parcel_data = Belpost::Models::ParcelBuilder.new
232
+ .with_type("package")
233
+ .with_attachment_type("products")
234
+ .with_weight(500)
235
+ .to_country("DE") # Германия
236
+ .with_declared_value(50, "USD")
237
+ .from_legal_person("ООО \"Компания\"")
238
+ .with_sender_location(
239
+ postal_code: "225212",
240
+ region: "Брестская",
241
+ district: "Березовский",
242
+ locality_type: "город",
243
+ locality_name: "Береза",
244
+ road_type: "улица",
245
+ road_name: "Ленина",
246
+ building: "1"
247
+ )
248
+ .with_sender_contact(
249
+ email: "test@example.com",
250
+ phone: "375291234567"
251
+ )
252
+ .to_natural_person(
253
+ first_name: "John",
254
+ last_name: "Doe"
255
+ )
256
+ .with_foreign_recipient_location(
257
+ postal_code: "10115",
258
+ locality: "Berlin",
259
+ address: "Unter den Linden 77"
260
+ )
261
+ .with_recipient_contact(
262
+ phone: "4901234567890"
263
+ )
264
+ .with_customs_declaration(customs_declaration)
265
+ .build
266
+
267
+ response = client.create_parcel(parcel_data)
268
+ puts "Трекинг код: #{response["data"]["parcel"]["s10code"]}"
269
+ ```
270
+
271
+ ### Получение списка доступных стран
272
+
273
+ ```ruby
274
+ client = Belpost::Client.new
275
+ countries = client.fetch_available_countries
276
+ puts countries
277
+ ```
278
+
279
+ ### Получение данных для валидации почтового отправления
280
+
281
+ ```ruby
282
+ client = Belpost::Client.new
283
+ validation_data = client.validate_postal_delivery("BY")
284
+ puts validation_data
285
+ ```
286
+
287
+ ### Получение кодов HS для таможенного декларирования
288
+
289
+ ```ruby
290
+ client = Belpost::Client.new
291
+ hs_codes = client.fetch_hs_codes
292
+ puts hs_codes
293
+ ```
294
+
295
+ ## Обработка ошибок
296
+
297
+ Клиент может выбрасывать следующие исключения:
298
+
299
+ - `Belpost::ConfigurationError` - ошибка конфигурации
300
+ - `Belpost::ValidationError` - ошибка валидации данных
301
+ - `Belpost::ApiError` - базовая ошибка API
302
+ - `Belpost::AuthenticationError` - ошибка аутентификации
303
+ - `Belpost::InvalidRequestError` - ошибка запроса
304
+ - `Belpost::RateLimitError` - превышен лимит запросов
305
+ - `Belpost::ServerError` - ошибка сервера
306
+ - `Belpost::NetworkError` - сетевая ошибка
307
+ - `Belpost::TimeoutError` - таймаут запроса
308
+
309
+ Пример обработки ошибок:
310
+
311
+ ```ruby
312
+ begin
313
+ client = Belpost::Client.new
314
+ response = client.create_parcel(parcel_data)
315
+ rescue Belpost::ValidationError => e
316
+ puts "Ошибка валидации: #{e.message}"
317
+ rescue Belpost::AuthenticationError => e
318
+ puts "Ошибка аутентификации: #{e.message}"
319
+ rescue Belpost::ApiError => e
320
+ puts "Ошибка API: #{e.message}"
321
+ end
322
+ ```
323
+
324
+ ## Документация
325
+
326
+ Полная документация по API Белпочты доступна в официальной документации.
327
+
328
+ ## Разработка
329
+
330
+ После клонирования репозитория выполните `bin/setup` для установки зависимостей. Затем выполните `rake spec` для запуска тестов.
331
+
332
+ ## Contributing
333
+
334
+ Bug reports and pull requests are welcome on GitHub.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/belpost.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/belpost/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "belpost"
7
+ spec.version = Belpost::VERSION
8
+ spec.authors = ["KuberLite"]
9
+ spec.email = ["kuberlite@gmail.com"]
10
+
11
+ spec.summary = "Belpost API wrapper"
12
+ spec.description = "Gem for working with the 'Belpost' delivery service via API"
13
+ spec.homepage = "https://github.com/KuberLite/belpost"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
+ spec.license = "MIT"
16
+
17
+ spec.metadata = {
18
+ "bug_tracker_uri" => "https://github.com/KuberLite/belpost/issues",
19
+ "changelog_uri" => "https://github.com/KuberLite/belpost/releases",
20
+ "source_code_uri" => "https://github.com/belpost/evropochta",
21
+ "homepage_uri" => spec.homepage,
22
+ "rubygems_mfa_required" => "true"
23
+ }
24
+
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (File.expand_path(f) == __FILE__) ||
28
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ spec.add_dependency "dotenv"
36
+ spec.add_dependency "dry-validation", "~> 1.0"
37
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "logger"
6
+
7
+ module Belpost
8
+ # Service class for handling HTTP requests to the BelPost API.
9
+ class ApiService
10
+ # Initializes a new instance of the ApiService.
11
+ #
12
+ # @param base_url [String] The base URL of the API.
13
+ # @param jwt_token [String] The JWT token for authentication.
14
+ # @param timeout [Integer] The request timeout in seconds (default: 10).
15
+ # @param logger [Logger] The logger for logging requests and responses.
16
+ def initialize(base_url:, jwt_token:, timeout: 10, logger: Logger.new($stdout))
17
+ @base_url = base_url
18
+ @jwt_token = jwt_token
19
+ @timeout = timeout
20
+ @logger = logger
21
+ end
22
+
23
+ # Performs a GET request to the specified path.
24
+ #
25
+ # @param path [String] The API endpoint path.
26
+ # @return [Models::ApiResponse] The parsed JSON response from the API.
27
+ def get(path)
28
+ Retry.with_retry do
29
+ uri = URI("#{@base_url}#{path}")
30
+ request = Net::HTTP::Get.new(uri)
31
+ add_headers(request)
32
+
33
+ log_request(request)
34
+ response = execute_request(uri, request)
35
+ log_response(response)
36
+
37
+ begin
38
+ Models::ApiResponse.new(
39
+ data: JSON.parse(response.body),
40
+ status_code: response.code.to_i,
41
+ headers: response.to_hash
42
+ )
43
+ rescue JSON::ParserError => e
44
+ raise ParseError, "Failed to parse JSON response: #{e.message}"
45
+ end
46
+ end
47
+ end
48
+
49
+ # Performs a POST request to the specified path with the given body.
50
+ #
51
+ # @param path [String] The API endpoint path.
52
+ # @param body [Hash] The request body as a hash.
53
+ # @return [Models::ApiResponse] The parsed JSON response from the API.
54
+ def post(path, body)
55
+ Retry.with_retry do
56
+ uri = URI("#{@base_url}#{path}")
57
+ request = Net::HTTP::Post.new(uri)
58
+ add_headers(request)
59
+ request.body = body.to_json
60
+
61
+ log_request(request)
62
+ response = execute_request(uri, request)
63
+ log_response(response)
64
+
65
+ begin
66
+ Models::ApiResponse.new(
67
+ data: JSON.parse(response.body),
68
+ status_code: response.code.to_i,
69
+ headers: response.to_hash
70
+ )
71
+ rescue JSON::ParserError => e
72
+ raise ParseError, "Failed to parse JSON response: #{e.message}"
73
+ end
74
+ end
75
+ end
76
+
77
+ # Performs a PUT request to the specified path with the given body.
78
+ #
79
+ # @param path [String] The API endpoint path.
80
+ # @param body [Hash] The request body as a hash.
81
+ # @return [Models::ApiResponse] The parsed JSON response from the API.
82
+ def put(path, body)
83
+ Retry.with_retry do
84
+ uri = URI("#{@base_url}#{path}")
85
+ request = Net::HTTP::Put.new(uri)
86
+ add_headers(request)
87
+ request.body = body.to_json
88
+
89
+ log_request(request)
90
+ response = execute_request(uri, request)
91
+ log_response(response)
92
+
93
+ begin
94
+ Models::ApiResponse.new(
95
+ data: JSON.parse(response.body),
96
+ status_code: response.code.to_i,
97
+ headers: response.to_hash
98
+ )
99
+ rescue JSON::ParserError => e
100
+ raise ParseError, "Failed to parse JSON response: #{e.message}"
101
+ end
102
+ end
103
+ end
104
+
105
+ # Performs a DELETE request to the specified path.
106
+ #
107
+ # @param path [String] The API endpoint path.
108
+ # @return [Models::ApiResponse] The parsed JSON response from the API.
109
+ def delete(path)
110
+ Retry.with_retry do
111
+ uri = URI("#{@base_url}#{path}")
112
+ request = Net::HTTP::Delete.new(uri)
113
+ add_headers(request)
114
+
115
+ log_request(request)
116
+ response = execute_request(uri, request)
117
+ log_response(response)
118
+
119
+ begin
120
+ Models::ApiResponse.new(
121
+ data: JSON.parse(response.body),
122
+ status_code: response.code.to_i,
123
+ headers: response.to_hash
124
+ )
125
+ rescue JSON::ParserError => e
126
+ raise ParseError, "Failed to parse JSON response: #{e.message}"
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ # Adds standard headers to the HTTP request.
134
+ #
135
+ # @param request [Net::HTTP::Request] The HTTP request object.
136
+ def add_headers(request)
137
+ request["Authorization"] = "Bearer #{@jwt_token}"
138
+ request["Accept"] = "application/json"
139
+ request["Content-Type"] = "application/json"
140
+ end
141
+
142
+ # Executes the HTTP request and processes the response.
143
+ #
144
+ # @param uri [URI] The URI of the request.
145
+ # @param request [Net::HTTP::Request] The HTTP request object.
146
+ # @return [Net::HTTP::Response] The HTTP response object.
147
+ # @raise [Belpost::ApiError] If the API returns an error response.
148
+ def execute_request(uri, request)
149
+ http = Net::HTTP.new(uri.host, uri.port)
150
+ http.use_ssl = true if uri.scheme == "https"
151
+ http.read_timeout = @timeout
152
+
153
+ begin
154
+ response = http.request(request)
155
+ handle_response(response)
156
+ rescue Net::OpenTimeout, Net::ReadTimeout
157
+ raise RequestError, "Request timed out after #{@timeout} seconds"
158
+ rescue Net::HTTPError => e
159
+ case e.response.code
160
+ when "401", "403"
161
+ raise AuthenticationError.new(
162
+ "Authentication failed",
163
+ status_code: e.response.code.to_i,
164
+ response_body: e.response.body
165
+ )
166
+ when "429"
167
+ raise RateLimitError.new(
168
+ "Rate limit exceeded",
169
+ status_code: e.response.code.to_i,
170
+ response_body: e.response.body
171
+ )
172
+ when "400"
173
+ raise InvalidRequestError.new(
174
+ "Invalid request",
175
+ status_code: e.response.code.to_i,
176
+ response_body: e.response.body
177
+ )
178
+ else
179
+ raise ServerError.new(
180
+ "Server error",
181
+ status_code: e.response.code.to_i,
182
+ response_body: e.response.body
183
+ )
184
+ end
185
+ rescue StandardError => e
186
+ raise NetworkError, "Network error: #{e.message}"
187
+ end
188
+ end
189
+
190
+ def handle_response(response)
191
+ case response.code
192
+ when "200"
193
+ response
194
+ when "401", "403"
195
+ raise AuthenticationError.new(
196
+ "Authentication failed",
197
+ status_code: response.code.to_i,
198
+ response_body: response.body
199
+ )
200
+ when "429"
201
+ raise RateLimitError.new(
202
+ "Rate limit exceeded",
203
+ status_code: response.code.to_i,
204
+ response_body: response.body
205
+ )
206
+ when "400"
207
+ raise InvalidRequestError.new(
208
+ "Invalid request",
209
+ status_code: response.code.to_i,
210
+ response_body: response.body
211
+ )
212
+ else
213
+ raise ServerError.new(
214
+ "Server error",
215
+ status_code: response.code.to_i,
216
+ response_body: response.body
217
+ )
218
+ end
219
+ end
220
+
221
+ def log_request(request)
222
+ @logger.info("Making #{request.method} request to #{request.uri}")
223
+ @logger.debug("Request headers: #{request.to_hash}")
224
+ @logger.debug("Request body: #{request.body}") if request.body
225
+ end
226
+
227
+ def log_response(response)
228
+ @logger.info("Received response with status #{response.code}")
229
+ @logger.debug("Response headers: #{response.to_hash}")
230
+ @logger.debug("Response body: #{response.body}")
231
+ end
232
+ end
233
+ end