cdek 0.3.10 → 0.3.11
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 +4 -4
- data/README.md +22 -1
- data/app/controllers/cdek/city_suggestions_controller.rb +20 -0
- data/app/controllers/cdek/widget_service_controller.rb +24 -5
- data/config/routes.rb +5 -1
- data/lib/cdek/city_resolver.rb +64 -0
- data/lib/cdek/city_suggestions.rb +70 -0
- data/lib/cdek/version.rb +1 -1
- data/lib/cdek.rb +12 -0
- data/lib/generators/cdek/install/templates/cdek_widget_controller.js +37 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c67295d1a6a2b9199d0c751a230ab6dc48ecda34fe855ec74eca1276efccf05
|
|
4
|
+
data.tar.gz: f43d4696e5f063d284329a2fb34fed6f9775807a2a79490623b7f1b6d0ef7526
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6ffcc0ddbfc9046c56bcc24246922a8755b492f8932d267298602dd7a49bcc447c88f2400ae562214759ff52441855889f67dd9508b7e56e50a7e4cdf11666c1
|
|
7
|
+
data.tar.gz: d6b4f68d0f9d992fb22b34f6c31298a0394b08072ae4165414f735d7586dff05e36d54b06e86cd65fd68040d62f8742109780a8fbad2d9c4a2a67a22edf9d4aa
|
data/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* OAuth2 client_credentials c потокобезопасным кэшем токена.
|
|
7
7
|
* Конфигурация через ENV или Rails-инициализатор.
|
|
8
8
|
* High-level ресурсы для частых задач: `Cdek.locations`, `Cdek.deliverypoints`.
|
|
9
|
+
* Поиск CDEK-кода города и JSON-подсказки городов для autocomplete.
|
|
9
10
|
* **Rails Engine** с прокси-эндпоинтом для официального JS-виджета ПВЗ
|
|
10
11
|
(cdek-it/widget@3).
|
|
11
12
|
* Вендорный UMD-бандл виджета — раздаётся через asset pipeline (без CDN).
|
|
@@ -62,6 +63,10 @@ Cdek.client.get("/deliverypoints", params: { city_code: 44, type: "PVZ" })
|
|
|
62
63
|
# High-level:
|
|
63
64
|
moscow = Cdek.locations.find_city("Москва")
|
|
64
65
|
points = Cdek.deliverypoints.pvz_for_city(moscow.fetch("code"))
|
|
66
|
+
|
|
67
|
+
# Город по пользовательскому вводу:
|
|
68
|
+
city_code = Cdek.city_code("Москва")
|
|
69
|
+
suggestions = Cdek.city_suggestions("мос")
|
|
65
70
|
```
|
|
66
71
|
|
|
67
72
|
## Виджет ПВЗ
|
|
@@ -98,7 +103,11 @@ points = Cdek.deliverypoints.pvz_for_city(moscow.fetch("code"))
|
|
|
98
103
|
2. JS-контроллер `cdek-widget` (поставлен генератором) подгружает
|
|
99
104
|
`/assets/cdek/widget.umd.js` (вшитый в гем UMD-бандл) и инициализирует
|
|
100
105
|
`window.CDEKWidget` в root-таргете.
|
|
101
|
-
3. Виджет шлёт запросы на `/cdek/widget_service` (Engine route).
|
|
106
|
+
3. Виджет шлёт запросы на `/cdek/widget_service` (Engine route). Если
|
|
107
|
+
`default_city` передан строкой, Stimulus-контроллер добавляет к servicePath
|
|
108
|
+
внутренний параметр `widget_city`, а Engine преобразует его в `city_code`
|
|
109
|
+
перед запросом `/deliverypoints`. Это не даёт виджету загружать все ПВЗ
|
|
110
|
+
страны при открытии карты.
|
|
102
111
|
4. На `onChoose` контроллер пишет данные выбранного пункта в скрытые поля
|
|
103
112
|
формы — по умолчанию:
|
|
104
113
|
|
|
@@ -113,6 +122,18 @@ points = Cdek.deliverypoints.pvz_for_city(moscow.fetch("code"))
|
|
|
113
122
|
5. Также диспатчится событие `cdek-widget:chosen` с `detail.office` —
|
|
114
123
|
можно слушать в собственных Stimulus-контроллерах.
|
|
115
124
|
|
|
125
|
+
### Подсказки городов
|
|
126
|
+
|
|
127
|
+
Engine предоставляет read-only JSON endpoint для autocomplete:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
GET /cdek/city_suggestions?q=мос
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Ответ — массив нормализованных объектов с `code`, `city`, `region`,
|
|
134
|
+
`country`, `country_code` и готовым `label`. UI поля ввода, debounce и
|
|
135
|
+
выпадающий список остаются на стороне хост-приложения.
|
|
136
|
+
|
|
116
137
|
### Закрытие модалки
|
|
117
138
|
|
|
118
139
|
Если виджет встроен в модалку, передайте её `id` в `modal_id:` — после
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cdek
|
|
4
|
+
# Public read-only endpoint for city autocomplete in host applications.
|
|
5
|
+
class CitySuggestionsController < ::ActionController::Base
|
|
6
|
+
def index
|
|
7
|
+
render json: Cdek.city_suggestions(
|
|
8
|
+
params[:q],
|
|
9
|
+
country_code: params[:country_code].presence || Cdek::CitySuggestions::DEFAULT_COUNTRY_CODE
|
|
10
|
+
)
|
|
11
|
+
rescue Cdek::ConfigurationError => e
|
|
12
|
+
render json: { message: e.message }, status: :service_unavailable
|
|
13
|
+
rescue Cdek::ApiError => e
|
|
14
|
+
status = e.status.to_i.positive? ? e.status : 502
|
|
15
|
+
render json: { message: e.message, errors: e.errors, body: e.body }, status: status
|
|
16
|
+
rescue Cdek::Error => e
|
|
17
|
+
render json: { message: e.message }, status: :bad_gateway
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -38,6 +38,11 @@ module Cdek
|
|
|
38
38
|
EXCLUDED_PARAM_KEYS = %w[action controller format].freeze
|
|
39
39
|
private_constant :EXCLUDED_PARAM_KEYS
|
|
40
40
|
|
|
41
|
+
# Дополнительный параметр engine'а: хост-приложение может передать
|
|
42
|
+
# пользовательский город текстом, а gem преобразует его в city_code CDEK.
|
|
43
|
+
WIDGET_CITY_KEY = "widget_city"
|
|
44
|
+
private_constant :WIDGET_CITY_KEY
|
|
45
|
+
|
|
41
46
|
def call
|
|
42
47
|
cdek_action = cdek_request_action
|
|
43
48
|
|
|
@@ -67,12 +72,26 @@ module Cdek
|
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
# Все параметры запроса, кроме служебных Rails-роутинга и Rails-обёртки.
|
|
70
|
-
# Передаются в CDEK API как
|
|
75
|
+
# Передаются в CDEK API как есть, кроме widget_city: это внутренний
|
|
76
|
+
# параметр engine'а, который нормализуется в CDEK city_code.
|
|
71
77
|
def cdek_filtered_params
|
|
72
|
-
request.query_parameters
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
raw_params = request.query_parameters
|
|
79
|
+
.merge(request.request_parameters)
|
|
80
|
+
.except(*EXCLUDED_PARAM_KEYS, WRAPPER_KEY)
|
|
81
|
+
.to_h
|
|
82
|
+
|
|
83
|
+
cdek_params_with_city_code(raw_params)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cdek_params_with_city_code(raw_params)
|
|
87
|
+
params = raw_params.except(WIDGET_CITY_KEY)
|
|
88
|
+
city_code = raw_params["city_code"].presence || Cdek.city_code(raw_params[WIDGET_CITY_KEY])
|
|
89
|
+
|
|
90
|
+
if city_code.present?
|
|
91
|
+
params.merge("city_code" => city_code).compact
|
|
92
|
+
else
|
|
93
|
+
params.compact
|
|
94
|
+
end
|
|
76
95
|
end
|
|
77
96
|
end
|
|
78
97
|
end
|
data/config/routes.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Cdek::Engine.routes.draw do
|
|
4
|
-
# Прокси для JS-виджета
|
|
4
|
+
# Прокси для JS-виджета ПВЗ.
|
|
5
5
|
# Виджет шлёт GET (action=offices) и POST (action=calculate) на один URL,
|
|
6
6
|
# поэтому match по обоим методам.
|
|
7
7
|
match "/widget_service",
|
|
8
8
|
to: "widget_service#call",
|
|
9
9
|
via: %i[get post],
|
|
10
10
|
as: :widget_service
|
|
11
|
+
|
|
12
|
+
get "/city_suggestions",
|
|
13
|
+
to: "city_suggestions#index",
|
|
14
|
+
as: :city_suggestions
|
|
11
15
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cdek
|
|
4
|
+
# Resolves a human-readable city name to a CDEK city code.
|
|
5
|
+
#
|
|
6
|
+
# This is intentionally small and API-backed: host applications can pass
|
|
7
|
+
# user-entered city names to the widget proxy without duplicating lookup
|
|
8
|
+
# logic or loading all delivery points for the whole country.
|
|
9
|
+
class CityResolver
|
|
10
|
+
DEFAULT_COUNTRY_CODES = "RU"
|
|
11
|
+
CACHE_EXPIRES_IN = 43_200
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def call(name, **options)
|
|
15
|
+
new(name, **options).call
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(name, client: Cdek.client, country_codes: DEFAULT_COUNTRY_CODES, cache: default_cache)
|
|
20
|
+
@name = name
|
|
21
|
+
@client = client
|
|
22
|
+
@country_codes = country_codes
|
|
23
|
+
@cache = cache
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
normalized_name.empty? ? nil : cached_city_code
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :name, :client, :country_codes, :cache
|
|
33
|
+
|
|
34
|
+
def cached_city_code
|
|
35
|
+
if cache
|
|
36
|
+
cache.fetch(cache_key, expires_in: CACHE_EXPIRES_IN) { fetch_city_code }
|
|
37
|
+
else
|
|
38
|
+
fetch_city_code
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch_city_code
|
|
43
|
+
city = Cdek.locations(client).find_city(normalized_name, country_codes: country_codes)
|
|
44
|
+
|
|
45
|
+
if city.is_a?(Hash)
|
|
46
|
+
city["code"] || city[:code]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalized_name
|
|
51
|
+
@normalized_name ||= name.to_s.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cache_key
|
|
55
|
+
["cdek", "city_code", country_codes, normalized_name.downcase].join(":")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_cache
|
|
59
|
+
if defined?(::Rails) && ::Rails.respond_to?(:cache)
|
|
60
|
+
::Rails.cache
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cdek
|
|
4
|
+
# Builds normalized city suggestions for host application autocomplete UIs.
|
|
5
|
+
class CitySuggestions
|
|
6
|
+
DEFAULT_COUNTRY_CODE = "RU"
|
|
7
|
+
DEFAULT_LIMIT = 10
|
|
8
|
+
MIN_QUERY_LENGTH = 2
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def call(query, **options)
|
|
12
|
+
new(query, **options).call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(query, client: Cdek.client, country_code: DEFAULT_COUNTRY_CODE, limit: DEFAULT_LIMIT)
|
|
17
|
+
@query = query
|
|
18
|
+
@client = client
|
|
19
|
+
@country_code = country_code
|
|
20
|
+
@limit = limit
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
normalized_query.length < MIN_QUERY_LENGTH ? [] : normalized_suggestions
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :query, :client, :country_code, :limit
|
|
30
|
+
|
|
31
|
+
def normalized_suggestions
|
|
32
|
+
Array(raw_suggestions).first(limit).filter_map do |city|
|
|
33
|
+
suggestion_payload(city)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def raw_suggestions
|
|
38
|
+
Cdek.locations(client).suggest_cities(name: normalized_query, country_code: country_code)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def suggestion_payload(city)
|
|
42
|
+
if city.is_a?(Hash)
|
|
43
|
+
code = city["code"] || city[:code]
|
|
44
|
+
city_name = city["city"] || city[:city] || city["name"] || city[:name]
|
|
45
|
+
region = city["region"] || city[:region]
|
|
46
|
+
country = city["country"] || city[:country]
|
|
47
|
+
country_code_value = city["country_code"] || city[:country_code]
|
|
48
|
+
|
|
49
|
+
if code && city_name
|
|
50
|
+
{
|
|
51
|
+
code: code,
|
|
52
|
+
city: city_name,
|
|
53
|
+
region: region,
|
|
54
|
+
country: country,
|
|
55
|
+
country_code: country_code_value,
|
|
56
|
+
label: label_for(city_name, region)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def label_for(city_name, region)
|
|
63
|
+
[city_name, region].compact.map(&:to_s).reject(&:empty?).uniq.join(", ")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalized_query
|
|
67
|
+
@normalized_query ||= query.to_s.strip
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/cdek/version.rb
CHANGED
data/lib/cdek.rb
CHANGED
|
@@ -8,6 +8,8 @@ require "cdek/client"
|
|
|
8
8
|
require "cdek/resources/base"
|
|
9
9
|
require "cdek/resources/locations"
|
|
10
10
|
require "cdek/resources/deliverypoints"
|
|
11
|
+
require "cdek/city_resolver"
|
|
12
|
+
require "cdek/city_suggestions"
|
|
11
13
|
|
|
12
14
|
module Cdek
|
|
13
15
|
CLIENT_MUTEX = Mutex.new
|
|
@@ -60,6 +62,16 @@ module Cdek
|
|
|
60
62
|
def deliverypoints(custom_client = client)
|
|
61
63
|
Resources::Deliverypoints.new(custom_client)
|
|
62
64
|
end
|
|
65
|
+
|
|
66
|
+
# CDEK-код города по пользовательскому названию.
|
|
67
|
+
def city_code(name, **options)
|
|
68
|
+
CityResolver.call(name, **options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Нормализованные подсказки городов для autocomplete в хост-приложении.
|
|
72
|
+
def city_suggestions(query, **options)
|
|
73
|
+
CitySuggestions.call(query, **options)
|
|
74
|
+
end
|
|
63
75
|
end
|
|
64
76
|
end
|
|
65
77
|
|
|
@@ -90,12 +90,16 @@ export default class extends Controller {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
connect() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.catch((err) => this._showError(err && err.message ? err.message : "ошибка"))
|
|
93
|
+
this._connected = true
|
|
94
|
+
this._mountWhenReady()
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
disconnect() {
|
|
98
|
+
this._connected = false
|
|
99
|
+
if (this._mountTimer) {
|
|
100
|
+
window.clearTimeout(this._mountTimer)
|
|
101
|
+
this._mountTimer = null
|
|
102
|
+
}
|
|
99
103
|
if (this._widget) {
|
|
100
104
|
try {
|
|
101
105
|
if (typeof this._widget.destroy === "function") this._widget.destroy()
|
|
@@ -105,6 +109,35 @@ export default class extends Controller {
|
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
_mountWhenReady() {
|
|
113
|
+
if (!this._connected || this._widget || this._mounting) return
|
|
114
|
+
if (!this._isRootVisible()) {
|
|
115
|
+
this._mountTimer = window.setTimeout(() => this._mountWhenReady(), 50)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._mounting = true
|
|
120
|
+
ensureWidgetScript(this.scriptUrlValue)
|
|
121
|
+
.then(() => this._mountWidget())
|
|
122
|
+
.catch((err) => this._showError(err && err.message ? err.message : "ошибка"))
|
|
123
|
+
.finally(() => { this._mounting = false })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_isRootVisible() {
|
|
127
|
+
if (!this.hasRootTarget) return false
|
|
128
|
+
const rect = this.rootTarget.getBoundingClientRect()
|
|
129
|
+
return rect.width > 0 && rect.height > 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_servicePath() {
|
|
133
|
+
const url = new URL(this.servicePathValue, window.location.origin)
|
|
134
|
+
const widgetCity = this.defaultLocationValue.trim()
|
|
135
|
+
if (widgetCity !== "") {
|
|
136
|
+
url.searchParams.set("widget_city", widgetCity)
|
|
137
|
+
}
|
|
138
|
+
return url.pathname + url.search
|
|
139
|
+
}
|
|
140
|
+
|
|
108
141
|
_mountWidget() {
|
|
109
142
|
if (!this.hasRootTarget) return
|
|
110
143
|
if (typeof window.CDEKWidget !== "function") {
|
|
@@ -129,7 +162,7 @@ export default class extends Controller {
|
|
|
129
162
|
const goods = parseGoods(this.goodsValue)
|
|
130
163
|
const widgetOptions = {
|
|
131
164
|
root: this.rootTarget.id,
|
|
132
|
-
servicePath: this.
|
|
165
|
+
servicePath: this._servicePath(),
|
|
133
166
|
apiKey: this.apiKeyValue,
|
|
134
167
|
defaultLocation: this.defaultLocationValue,
|
|
135
168
|
from: from,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cdek
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergey Korolyov
|
|
@@ -36,11 +36,14 @@ files:
|
|
|
36
36
|
- LICENSE
|
|
37
37
|
- README.md
|
|
38
38
|
- app/assets/javascripts/cdek/widget.umd.js
|
|
39
|
+
- app/controllers/cdek/city_suggestions_controller.rb
|
|
39
40
|
- app/controllers/cdek/widget_service_controller.rb
|
|
40
41
|
- app/helpers/cdek/widget_helper.rb
|
|
41
42
|
- cdek.gemspec
|
|
42
43
|
- config/routes.rb
|
|
43
44
|
- lib/cdek.rb
|
|
45
|
+
- lib/cdek/city_resolver.rb
|
|
46
|
+
- lib/cdek/city_suggestions.rb
|
|
44
47
|
- lib/cdek/client.rb
|
|
45
48
|
- lib/cdek/configuration.rb
|
|
46
49
|
- lib/cdek/connection.rb
|