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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE +21 -0
- data/README.md +138 -0
- data/app/assets/javascripts/cdek/widget.umd.js +2 -0
- data/app/controllers/cdek/widget_service_controller.rb +78 -0
- data/app/helpers/cdek/widget_helper.rb +133 -0
- data/cdek.gemspec +38 -0
- data/config/routes.rb +11 -0
- data/lib/cdek/client.rb +41 -0
- data/lib/cdek/configuration.rb +56 -0
- data/lib/cdek/connection.rb +228 -0
- data/lib/cdek/engine.rb +46 -0
- data/lib/cdek/error.rb +31 -0
- data/lib/cdek/railtie.rb +11 -0
- data/lib/cdek/resources/base.rb +17 -0
- data/lib/cdek/resources/deliverypoints.rb +50 -0
- data/lib/cdek/resources/locations.rb +47 -0
- data/lib/cdek/version.rb +5 -0
- data/lib/cdek.rb +69 -0
- data/lib/generators/cdek/install/install_generator.rb +56 -0
- data/lib/generators/cdek/install/templates/cdek.rb +32 -0
- data/lib/generators/cdek/install/templates/cdek_widget_controller.js +184 -0
- metadata +78 -0
|
@@ -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
|
data/lib/cdek/client.rb
ADDED
|
@@ -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
|
data/lib/cdek/engine.rb
ADDED
|
@@ -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
|