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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6463b54566dc996e5be0cdd477b9d82351d13fa656fcaa69cd616316ae1f383
4
- data.tar.gz: 61bf32c28513536ebbf227f748eba6a9d86a5498ed03f3f9484cac3f03bb974a
3
+ metadata.gz: 7c67295d1a6a2b9199d0c751a230ab6dc48ecda34fe855ec74eca1276efccf05
4
+ data.tar.gz: f43d4696e5f063d284329a2fb34fed6f9775807a2a79490623b7f1b6d0ef7526
5
5
  SHA512:
6
- metadata.gz: ac67d9d0ef2bc0bfc0fc7867956665a912405d4083a2a949be13c3debfc76c8c54e8fc572929c44c701b006866dbbdae9106f3d788232a30a7ebbaff8a8c18e1
7
- data.tar.gz: a9fc17a09adb1410247373d28afd0ab96793f9c52668f5668e12f752b5a88379ab64f0565901dc219753446588d9e57d0c735b7a82e6d0e849472b85fd246ffa
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
- .merge(request.request_parameters)
74
- .except(*EXCLUDED_PARAM_KEYS, WRAPPER_KEY)
75
- .to_h
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cdek
4
- VERSION = "0.3.10"
4
+ VERSION = "0.3.11"
5
5
  end
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
- ensureWidgetScript(this.scriptUrlValue)
94
- .then(() => this._mountWidget())
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.servicePathValue,
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.10
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