cdek 0.3.9

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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ # Прокси-эндпоинт для JS-виджета ПВЗ СДЭК (cdek-it/widget@3).
5
+ #
6
+ # Виджет ожидает servicePath, который умеет:
7
+ # * GET ?action=offices&<фильтры> -> CDEK API /deliverypoints
8
+ # * POST {"action":"calculate", ...} -> CDEK API /calculator/tarifflist
9
+ #
10
+ # Это Ruby-аналог dist/service.php из репозитория cdek-it/widget,
11
+ # построенный поверх нашего тонкого клиента (он же берёт на себя OAuth2 и
12
+ # маппинг ошибок).
13
+ #
14
+ # Эндпоинт публичный read-only — не меняет состояние хост-приложения,
15
+ # авторизация выполняется на нашей стороне CDEK-токенами. CSRF полностью
16
+ # пропускаем (skip_forgery_protection) — виджет не знает о Rails-токенах,
17
+ # а raise/null_session засоряют логи строкой
18
+ # "Can't verify CSRF token authenticity" на каждый POST виджета.
19
+ #
20
+ # ВАЖНО: wrap_parameters принудительно отключён. По умолчанию Rails для
21
+ # JSON-запросов оборачивает body в ключ, совпадающий с именем контроллера
22
+ # (`widget_service`), и это удваивает все поля запроса. Удвоенные поля
23
+ # утекали в CDEK API как лишний ключ "widget_service" — CDEK его молча
24
+ # игнорировал (200 OK), но логи и сетевой трафик засорялись копией.
25
+ class WidgetServiceController < ::ActionController::Base
26
+ skip_forgery_protection
27
+ wrap_parameters false
28
+
29
+ # Имя ключа, под который Rails-обёртка кладёт дубликат параметров,
30
+ # если wrap_parameters всё же сработает (например, при переопределении
31
+ # на уровне ApplicationController наследниками). Удаляется на всякий
32
+ # случай вторым эшелоном защиты.
33
+ WRAPPER_KEY = "widget_service"
34
+ private_constant :WRAPPER_KEY
35
+
36
+ # Служебные ключи Rails-роутинга и виджета, которые не должны утечь
37
+ # в тело запроса к CDEK API.
38
+ EXCLUDED_PARAM_KEYS = %w[action controller format].freeze
39
+ private_constant :EXCLUDED_PARAM_KEYS
40
+
41
+ def call
42
+ cdek_action = cdek_request_action
43
+
44
+ case cdek_action
45
+ when "offices"
46
+ render json: Cdek.client.get("/deliverypoints", params: cdek_filtered_params)
47
+ when "calculate"
48
+ render json: Cdek.client.post("/calculator/tarifflist", body: cdek_filtered_params)
49
+ else
50
+ render json: { message: "Unknown action: #{cdek_action.inspect}" }, status: :bad_request
51
+ end
52
+ rescue Cdek::ConfigurationError => e
53
+ render json: { message: e.message }, status: :service_unavailable
54
+ rescue Cdek::ApiError => e
55
+ status = e.status.to_i.positive? ? e.status : 502
56
+ render json: { message: e.message, errors: e.errors, body: e.body }, status: status
57
+ rescue Cdek::Error => e
58
+ render json: { message: e.message }, status: :bad_gateway
59
+ end
60
+
61
+ private
62
+
63
+ # `action` виджет шлёт либо как query-параметр (GET), либо в JSON-теле (POST).
64
+ def cdek_request_action
65
+ request.query_parameters["action"].presence ||
66
+ request.request_parameters["action"].presence
67
+ end
68
+
69
+ # Все параметры запроса, кроме служебных Rails-роутинга и Rails-обёртки.
70
+ # Передаются в CDEK API как есть.
71
+ def cdek_filtered_params
72
+ request.query_parameters
73
+ .merge(request.request_parameters)
74
+ .except(*EXCLUDED_PARAM_KEYS, WRAPPER_KEY)
75
+ .to_h
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Cdek
6
+ # Cdek::WidgetHelper — модуль, который Cdek::Engine автоматически подключает
7
+ # ко всем контроллерам хост-приложения. Делает доступным в любой view
8
+ # хелпер `cdek_widget_tag`, который рендерит блок-контейнер для JS-виджета
9
+ # ПВЗ СДЭК со всеми data-* атрибутами для Stimulus.
10
+ module WidgetHelper
11
+ # Рендерит блок-контейнер для JS-виджета. Виджет сам ставит карту,
12
+ # список ПВЗ и фильтры — нам нужно только дать ему div и Yandex-ключ.
13
+ #
14
+ # Параметры (все необязательные):
15
+ # api_key: ключ Yandex Maps JS API (по умолчанию — ENV["YANDEX_MAPS_API_KEY"])
16
+ # default_city: город «по умолчанию» (например, "Москва" или нечто из cookie)
17
+ # sender_city: город отправителя текстом (для тарифа в виджете, fallback)
18
+ # sender_city_code: CDEK-код города отправителя, число (предпочтительный
19
+ # способ — без него CDEK возвращает только дверь-* тарифы
20
+ # и виджет не может посчитать стоимость склад-склад,
21
+ # показывая "Выберите тариф").
22
+ # goods: массив хэшей габаритов и веса для расчёта тарифа.
23
+ # Каждый элемент: { width: Integer (см), height: Integer (см),
24
+ # length: Integer (см), weight: Integer (г) }. Гем не
25
+ # подставляет дефолтные габариты: хост-приложение должно
26
+ # собрать массив из реальных cart-items и передать сюда.
27
+ # modal_id: id модалки-обёртки — для авто-закрытия по выбору пункта
28
+ # field_*: DOM-id скрытых input'ов формы заказа, куда писать данные
29
+ # о выбранном пункте (по умолчанию совпадают с конвенциями,
30
+ # см. README)
31
+ # label_selector: CSS-селектор лейбла «Выбран пункт …» (по умолчанию
32
+ # `[data-cdek-widget-label]`)
33
+ # address_selector: CSS-селектор адреса выбранного пункта (по умолчанию
34
+ # `#order_cdek_point_address_view`)
35
+ # height: высота контейнера (по умолчанию `"600px"`).
36
+ def cdek_widget_tag(api_key: nil,
37
+ default_city: "Москва",
38
+ sender_city: "Москва",
39
+ sender_city_code: nil,
40
+ goods: nil,
41
+ modal_id: nil,
42
+ field_code: "order_cdek_point_code",
43
+ field_name: "order_cdek_point_name",
44
+ field_address: "order_cdek_point_address",
45
+ field_city_code: "order_cdek_city_code",
46
+ label_selector: "[data-cdek-widget-label]",
47
+ address_selector: "#order_cdek_point_address_view",
48
+ height: "600px")
49
+ goods_payload = goods.is_a?(Array) ? goods : []
50
+
51
+ data = cdek_widget_data(
52
+ api_key: api_key.presence || ENV["YANDEX_MAPS_API_KEY"].to_s,
53
+ default_city: default_city,
54
+ sender_city: sender_city,
55
+ sender_city_code: sender_city_code.to_s,
56
+ goods_json: JSON.generate(goods_payload),
57
+ service_path: cdek_engine_widget_service_path,
58
+ script_url: cdek_widget_asset_path,
59
+ modal_id: modal_id.to_s,
60
+ field_code: field_code,
61
+ field_name: field_name,
62
+ field_address: field_address,
63
+ field_city_code: field_city_code,
64
+ label_selector: label_selector,
65
+ address_selector: address_selector
66
+ )
67
+
68
+ # Box-model + position:relative — обязательные условия для корректной
69
+ # отрисовки JS-виджета. Виджет внутри использует absolute-позиционирование
70
+ # для overlay'ев карты, тултипов и поповеров; без позиционированного
71
+ # предка они привязываются к ближайшему позиционированному элементу
72
+ # (часто — корню документа), что выкидывает их визуально за пределы
73
+ # виджета. Поэтому ставим эти стили inline у себя, чтобы хост-приложение
74
+ # ничего дополнительно не требовалось настраивать.
75
+ content_tag :div, class: "cdek-widget",
76
+ data: data,
77
+ style: "display: block; position: relative; width: 100%; " \
78
+ "height: #{height}; min-height: #{height};" do
79
+ safe_join [
80
+ content_tag(:div, "",
81
+ class: "cdek-widget__root",
82
+ data: { cdek_widget_target: "root" },
83
+ style: "position: relative; width: 100%; height: 100%;"),
84
+ content_tag(:div, "",
85
+ class: "cdek-widget__error",
86
+ data: { cdek_widget_target: "error" },
87
+ style: "display: none;")
88
+ ]
89
+ end
90
+ end
91
+
92
+ # Хэш data-* атрибутов для Stimulus-контроллера cdek-widget.
93
+ # Ключи в snake_case — Rails сам конвертит "_" в "-" в HTML.
94
+ def cdek_widget_data(api_key:, default_city:, sender_city:, sender_city_code:,
95
+ goods_json:,
96
+ service_path:, script_url:, modal_id:,
97
+ field_code:, field_name:, field_address:, field_city_code:,
98
+ label_selector:, address_selector:)
99
+ {
100
+ controller: "cdek-widget",
101
+ cdek_widget_service_path_value: service_path,
102
+ cdek_widget_script_url_value: script_url,
103
+ cdek_widget_api_key_value: api_key,
104
+ cdek_widget_default_location_value: default_city,
105
+ cdek_widget_sender_city_value: sender_city,
106
+ cdek_widget_sender_city_code_value: sender_city_code,
107
+ cdek_widget_goods_value: goods_json,
108
+ cdek_widget_modal_id_value: modal_id,
109
+ cdek_widget_field_code_value: field_code,
110
+ cdek_widget_field_name_value: field_name,
111
+ cdek_widget_field_address_value: field_address,
112
+ cdek_widget_field_city_code_value: field_city_code,
113
+ cdek_widget_label_selector_value: label_selector,
114
+ cdek_widget_address_selector_value: address_selector
115
+ }
116
+ end
117
+
118
+ # Путь до UMD-бандла виджета, вшитого в гем. Используется JS-контроллером
119
+ # для динамической загрузки скрипта по требованию.
120
+ def cdek_widget_asset_path
121
+ if respond_to?(:asset_path)
122
+ asset_path("cdek/widget.umd.js")
123
+ else
124
+ "/assets/cdek/widget.umd.js"
125
+ end
126
+ end
127
+
128
+ # Путь до прокси-эндпоинта из routes engine'а — корректно подхватит mount-точку.
129
+ def cdek_engine_widget_service_path
130
+ Cdek::Engine.routes.url_helpers.widget_service_path
131
+ end
132
+ end
133
+ end
data/cdek.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cdek/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cdek"
7
+ spec.version = Cdek::VERSION
8
+ spec.authors = ["Your Name"]
9
+ spec.email = ["you@example.com"]
10
+
11
+ spec.summary = "Ruby/Rails клиент и Engine для CDEK API v2 с виджетом ПВЗ"
12
+ spec.description = "Тонкий клиент для CDEK API v2 без внешних рантайм-зависимостей: " \
13
+ "конфигурация, иерархия ошибок, автоматическое управление OAuth2-токеном. " \
14
+ "Поверх клиента — монтируемый Rails Engine с прокси-эндпоинтом, " \
15
+ "вендорным JS-виджетом ПВЗ и Stimulus-контроллером."
16
+ spec.license = "MIT"
17
+
18
+ spec.required_ruby_version = ">= 3.0"
19
+
20
+ # Включаем всё содержимое app/, config/ и lib/ (включая шаблоны генератора
21
+ # с расширениями .rb и .js, и вендорный UMD-бандл виджета).
22
+ spec.files = Dir[
23
+ "lib/**/*",
24
+ "app/**/*",
25
+ "config/**/*",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE",
29
+ "cdek.gemspec"
30
+ ]
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.metadata = {
34
+ "rubygems_mfa_required" => "true"
35
+ }
36
+
37
+ spec.add_development_dependency "rake", "~> 13.0"
38
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ Cdek::Engine.routes.draw do
4
+ # Прокси для JS-виджета ПВЗ — единственный публичный маршрут гема.
5
+ # Виджет шлёт GET (action=offices) и POST (action=calculate) на один URL,
6
+ # поэтому match по обоим методам.
7
+ match "/widget_service",
8
+ to: "widget_service#call",
9
+ via: %i[get post],
10
+ as: :widget_service
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ # Тонкий клиент-фасад над Cdek::Connection.
5
+ #
6
+ # Реализует «сырые» вызовы CDEK API v2 без дополнительной семантики.
7
+ # Высокоуровневые ресурсы (Calculator, Orders, Locations, Offices, Webhooks)
8
+ # будут добавлены в следующей итерации.
9
+ class Client
10
+ attr_reader :configuration, :connection
11
+
12
+ def initialize(configuration = Cdek.configuration)
13
+ @configuration = configuration
14
+ @connection = Connection.new(configuration)
15
+ end
16
+
17
+ def get(path, params: nil, headers: {})
18
+ connection.authenticated_request(:get, path, params: params, headers: headers)
19
+ end
20
+
21
+ def post(path, body: nil, headers: {})
22
+ connection.authenticated_request(:post, path, body: body, headers: headers)
23
+ end
24
+
25
+ def patch(path, body: nil, headers: {})
26
+ connection.authenticated_request(:patch, path, body: body, headers: headers)
27
+ end
28
+
29
+ def put(path, body: nil, headers: {})
30
+ connection.authenticated_request(:put, path, body: body, headers: headers)
31
+ end
32
+
33
+ def delete(path, params: nil, headers: {})
34
+ connection.authenticated_request(:delete, path, params: params, headers: headers)
35
+ end
36
+
37
+ def reset_token!
38
+ connection.reset_token!
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ # Конфигурация гема.
5
+ #
6
+ # Cdek.configure do |config|
7
+ # config.account = ENV["CDEK_ACCOUNT"]
8
+ # config.secure_password = ENV["CDEK_SECURE_PASSWORD"]
9
+ # config.production_mode!
10
+ # end
11
+ class Configuration
12
+ PRODUCTION_URL = "https://api.cdek.ru/v2"
13
+ TEST_URL = "https://api.edu.cdek.ru/v2"
14
+
15
+ # Тестовые учётные данные CDEK для песочницы публикуются в их документации.
16
+ # Указываются здесь только как ссылка для удобства, ничего не предзаполняется.
17
+ SANDBOX_ACCOUNT = "EMscd6r9JnFiQ3bLoyjJY6eM78JrJceI"
18
+ SANDBOX_SECURE_PASSWORD = "PjLZkKBHEiLK3YsHzqYDqQYj1pSwOaNB"
19
+
20
+ attr_accessor :account,
21
+ :secure_password,
22
+ :base_url,
23
+ :timeout,
24
+ :open_timeout,
25
+ :logger,
26
+ :user_agent
27
+
28
+ def initialize
29
+ @account = nil
30
+ @secure_password = nil
31
+ @base_url = TEST_URL
32
+ @timeout = 15
33
+ @open_timeout = 5
34
+ @logger = nil
35
+ @user_agent = "cdek-ruby/#{Cdek::VERSION}"
36
+ end
37
+
38
+ def test_mode!
39
+ @base_url = TEST_URL
40
+ end
41
+
42
+ def production_mode!
43
+ @base_url = PRODUCTION_URL
44
+ end
45
+
46
+ def use_sandbox_credentials!
47
+ @account = SANDBOX_ACCOUNT
48
+ @secure_password = SANDBOX_SECURE_PASSWORD
49
+ test_mode!
50
+ end
51
+
52
+ def credentials_present?
53
+ account.to_s.strip.length.positive? && secure_password.to_s.strip.length.positive?
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Cdek
8
+ # Низкоуровневый HTTP-коннект к CDEK API.
9
+ #
10
+ # Отвечает за:
11
+ # * получение и кэширование OAuth2 access_token (grant_type=client_credentials);
12
+ # * прозрачный ретрай запроса при 401 (один раз, со сбросом токена);
13
+ # * сериализацию JSON-тел и маппинг HTTP-статусов в иерархию Cdek::ApiError.
14
+ class Connection
15
+ JSON_CONTENT_TYPE = "application/json"
16
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
17
+ TOKEN_PATH = "/oauth/token"
18
+ TOKEN_REFRESH_LEEWAY = 30 # секунд до истечения, после которых обновляем заранее
19
+
20
+ HTTP_METHOD_CLASSES = {
21
+ get: Net::HTTP::Get,
22
+ post: Net::HTTP::Post,
23
+ patch: Net::HTTP::Patch,
24
+ put: Net::HTTP::Put,
25
+ delete: Net::HTTP::Delete
26
+ }.freeze
27
+
28
+ attr_reader :configuration
29
+
30
+ def initialize(configuration)
31
+ @configuration = configuration
32
+ @token_mutex = Mutex.new
33
+ @access_token = nil
34
+ @token_expires_at = nil
35
+ end
36
+
37
+ # Выполняет авторизованный запрос. Любой ответ 2xx — возвращает распарсенное тело.
38
+ # Любая ошибка — поднимается как Cdek::ApiError или его наследник.
39
+ def authenticated_request(method, path, params: nil, body: nil, headers: {})
40
+ execute_authenticated(method, path, params, body, headers, retried: false)
41
+ end
42
+
43
+ # Сбрасывает кэшированный токен — следующая операция получит новый.
44
+ def reset_token!
45
+ @token_mutex.synchronize do
46
+ @access_token = nil
47
+ @token_expires_at = nil
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def execute_authenticated(method, path, params, body, headers, retried:)
54
+ auth_headers = headers.merge("Authorization" => "Bearer #{access_token}")
55
+ raw = perform(method, path, params: params, body: body, headers: auth_headers)
56
+ outcome = handle_response(raw)
57
+
58
+ if outcome == :reauth
59
+ raise AuthenticationError.new("CDEK: повторный 401 после обновления токена") if retried
60
+
61
+ reset_token!
62
+ execute_authenticated(method, path, params, body, headers, retried: true)
63
+ else
64
+ outcome
65
+ end
66
+ end
67
+
68
+ def access_token
69
+ @token_mutex.synchronize do
70
+ fetch_token! if token_invalid?
71
+ @access_token
72
+ end
73
+ end
74
+
75
+ def token_invalid?
76
+ @access_token.nil? ||
77
+ @token_expires_at.nil? ||
78
+ Time.now >= (@token_expires_at - TOKEN_REFRESH_LEEWAY)
79
+ end
80
+
81
+ def fetch_token!
82
+ unless configuration.credentials_present?
83
+ raise ConfigurationError,
84
+ "CDEK: не заданы учётные данные. Установите account и secure_password через Cdek.configure"
85
+ end
86
+
87
+ form = URI.encode_www_form(
88
+ grant_type: "client_credentials",
89
+ client_id: configuration.account,
90
+ client_secret: configuration.secure_password
91
+ )
92
+
93
+ raw = perform_raw(:post, TOKEN_PATH,
94
+ body: form,
95
+ headers: { "Content-Type" => FORM_CONTENT_TYPE, "Accept" => JSON_CONTENT_TYPE })
96
+ parsed = parse_body(raw.body)
97
+
98
+ unless raw.is_a?(Net::HTTPSuccess)
99
+ raise AuthenticationError.new(
100
+ "CDEK: не удалось получить access_token",
101
+ status: raw.code.to_i,
102
+ body: parsed,
103
+ errors: extract_errors(parsed)
104
+ )
105
+ end
106
+
107
+ @access_token = parsed["access_token"]
108
+ @token_expires_at = Time.now + parsed["expires_in"].to_i
109
+ end
110
+
111
+ def perform(method, path, params: nil, body: nil, headers: {})
112
+ json_body = body.nil? ? nil : JSON.generate(body)
113
+ effective_headers = headers.merge(
114
+ "Content-Type" => JSON_CONTENT_TYPE,
115
+ "Accept" => JSON_CONTENT_TYPE
116
+ )
117
+ perform_raw(method, path, params: params, body: json_body, headers: effective_headers)
118
+ end
119
+
120
+ def perform_raw(method, path, params: nil, body: nil, headers: {})
121
+ uri = build_uri(path, params)
122
+ request = build_request(method, uri, body, headers)
123
+ log_request(method, uri, body)
124
+ execute_http(uri, request).tap { |response| log_response(response) }
125
+ end
126
+
127
+ def build_uri(path, params)
128
+ base = configuration.base_url.to_s.sub(%r{/+\z}, "")
129
+ normalized_path = path.start_with?("/") ? path : "/#{path}"
130
+ URI.parse("#{base}#{normalized_path}").tap do |uri|
131
+ uri.query = URI.encode_www_form(params) if params.is_a?(Hash) && params.any?
132
+ end
133
+ end
134
+
135
+ def build_request(method, uri, body, headers)
136
+ klass = HTTP_METHOD_CLASSES.fetch(method) do
137
+ raise ArgumentError, "Неподдерживаемый HTTP-метод: #{method.inspect}"
138
+ end
139
+
140
+ klass.new(uri.request_uri).tap do |req|
141
+ headers.each { |key, value| req[key] = value }
142
+ req["User-Agent"] = configuration.user_agent if configuration.user_agent
143
+ req.body = body if body
144
+ end
145
+ end
146
+
147
+ def execute_http(uri, request)
148
+ http = Net::HTTP.new(uri.host, uri.port)
149
+ http.use_ssl = (uri.scheme == "https")
150
+ http.read_timeout = configuration.timeout
151
+ http.open_timeout = configuration.open_timeout
152
+ http.request(request)
153
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
154
+ raise TimeoutError, "CDEK: таймаут запроса (#{e.message})"
155
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, IOError => e
156
+ raise ConnectionError, "CDEK: ошибка соединения (#{e.message})"
157
+ end
158
+
159
+ def handle_response(response)
160
+ status = response.code.to_i
161
+ parsed = parse_body(response.body)
162
+
163
+ case response
164
+ when Net::HTTPSuccess
165
+ parsed
166
+ when Net::HTTPUnauthorized
167
+ :reauth
168
+ when Net::HTTPBadRequest
169
+ raise BadRequestError.new("CDEK: 400 Bad Request",
170
+ status: status, body: parsed, errors: extract_errors(parsed))
171
+ when Net::HTTPNotFound
172
+ raise NotFoundError.new("CDEK: 404 Not Found",
173
+ status: status, body: parsed, errors: extract_errors(parsed))
174
+ when Net::HTTPTooManyRequests
175
+ raise RateLimitError.new("CDEK: 429 Too Many Requests",
176
+ status: status, body: parsed, errors: extract_errors(parsed))
177
+ when Net::HTTPServerError
178
+ raise ServerError.new("CDEK: серверная ошибка #{status}",
179
+ status: status, body: parsed, errors: extract_errors(parsed))
180
+ else
181
+ raise ApiError.new("CDEK: неожиданный статус #{status}",
182
+ status: status, body: parsed, errors: extract_errors(parsed))
183
+ end
184
+ end
185
+
186
+ def parse_body(body)
187
+ body.nil? || body.to_s.empty? ? {} : JSON.parse(body)
188
+ rescue JSON::ParserError
189
+ { "raw" => body.to_s }
190
+ end
191
+
192
+ def extract_errors(parsed)
193
+ parsed.is_a?(Hash) ? parsed["errors"] || parsed["requests"] : nil
194
+ end
195
+
196
+ def log_request(method, uri, body)
197
+ configuration.logger&.debug do
198
+ "[CDEK] #{method.to_s.upcase} #{uri} body=#{loggable_request_body(uri, body)}"
199
+ end
200
+ end
201
+
202
+ def log_response(response)
203
+ configuration.logger&.debug { "[CDEK] <- #{response.code} #{loggable_response_body(response)}" }
204
+ end
205
+
206
+ def loggable_request_body(uri, body)
207
+ if uri.path.end_with?(TOKEN_PATH)
208
+ "[FILTERED]"
209
+ else
210
+ body && truncate(body)
211
+ end
212
+ end
213
+
214
+ def loggable_response_body(response)
215
+ parsed = parse_body(response.body)
216
+
217
+ if parsed.is_a?(Hash) && parsed.key?("access_token")
218
+ truncate(JSON.generate(parsed.merge("access_token" => "[FILTERED]")))
219
+ else
220
+ truncate(response.body)
221
+ end
222
+ end
223
+
224
+ def truncate(text)
225
+ text.to_s[0, 500]
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Cdek
6
+ # Cdek::Engine — монтируемый Rails Engine, который шипит весь стек интеграции
7
+ # с виджетом ПВЗ СДЭК «в коробке»:
8
+ #
9
+ # * прокси-эндпоинт /widget_service (Cdek::WidgetServiceController)
10
+ # * вендорный JS /assets/cdek/widget.umd.js (через asset pipeline)
11
+ # * helper для view cdek_widget_tag (Cdek::WidgetHelper)
12
+ # * Stimulus-контроллер cdek_widget_controller.js (через генератор cdek:install)
13
+ #
14
+ # Подключение в хост-приложении:
15
+ #
16
+ # # config/routes.rb
17
+ # mount Cdek::Engine, at: "/cdek"
18
+ #
19
+ # # любая view
20
+ # = cdek_widget_tag api_key: ENV["YANDEX_MAPS_API_KEY"]
21
+ class Engine < ::Rails::Engine
22
+ isolate_namespace Cdek
23
+
24
+ # Перенесено из старого Cdek::Railtie — гарантируем, что у конфигурации
25
+ # есть логгер сразу после загрузки приложения.
26
+ initializer "cdek.set_default_logger" do
27
+ Cdek.configuration.logger ||= Rails.logger
28
+ end
29
+
30
+ # Asset pipeline (Sprockets / Propshaft) — точечно прекомпилируем UMD-бандл
31
+ # виджета, чтобы он попадал в production-сборку.
32
+ initializer "cdek.assets.precompile" do |app|
33
+ if app.config.respond_to?(:assets) && app.config.assets
34
+ app.config.assets.precompile += %w[cdek/widget.umd.js]
35
+ end
36
+ end
37
+
38
+ # Подключаем хелперы гема ко всем контроллерам хост-приложения,
39
+ # чтобы `cdek_widget_tag` был доступен в любой view без явных include.
40
+ initializer "cdek.include_helpers" do
41
+ ActiveSupport.on_load(:action_controller_base) do
42
+ helper Cdek::WidgetHelper
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/cdek/error.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cdek
4
+ # Базовый класс для всех ошибок гема.
5
+ class Error < StandardError; end
6
+
7
+ # Ошибка конфигурации (например, отсутствуют креды).
8
+ class ConfigurationError < Error; end
9
+
10
+ # Ошибка ответа API. Доступны статус, тело и массив ошибок CDEK.
11
+ class ApiError < Error
12
+ attr_reader :status, :body, :errors
13
+
14
+ def initialize(message, status: nil, body: nil, errors: nil)
15
+ super(message)
16
+ @status = status
17
+ @body = body
18
+ @errors = errors
19
+ end
20
+ end
21
+
22
+ class AuthenticationError < ApiError; end
23
+ class BadRequestError < ApiError; end
24
+ class NotFoundError < ApiError; end
25
+ class RateLimitError < ApiError; end
26
+ class ServerError < ApiError; end
27
+
28
+ # Ошибки сетевого уровня.
29
+ class ConnectionError < Error; end
30
+ class TimeoutError < ConnectionError; end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Cdek
6
+ class Railtie < Rails::Railtie
7
+ initializer "cdek.set_default_logger" do
8
+ Cdek.configuration.logger ||= Rails.logger
9
+ end
10
+ end
11
+ end