pratka 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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +21 -0
- data/README.md +460 -0
- data/lib/pratka/error.rb +5 -0
- data/lib/pratka/speedy/client.rb +232 -0
- data/lib/pratka/speedy/configuration.rb +38 -0
- data/lib/pratka/speedy/errors.rb +45 -0
- data/lib/pratka/speedy/http.rb +76 -0
- data/lib/pratka/speedy.rb +20 -0
- data/lib/pratka/util/string_helper.rb +17 -0
- data/lib/pratka/version.rb +5 -0
- data/lib/pratka.rb +9 -0
- data/sig/pratka.rbs +4 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '04085d85f88834e2907d322efdcf9ab8ccf3588514a5d362c19223f4cd22a790'
|
|
4
|
+
data.tar.gz: ff5582b3d13b92cad8e7fc36de4abd1f44462c67a58ea62031b66628aafedc04
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 47c29ce1d7858648f126d8f498c65232f7481e389049b469015dde5aa411a5cd83163dec1117426b97793cbb8fd5d7a146555e5617425c730e6ba52c30c05036
|
|
7
|
+
data.tar.gz: ee5af3f162ba6c5f5a6a893e096e4bfb41bef64a72f30f43ae305ffd152daa2f21a883317dff79b7b03186f117e2c49e728fae76a730fc5bcb745a83d20343ee
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-07
|
|
4
|
+
|
|
5
|
+
Initial release, with Speedy as the first supported courier.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Pratka::Speedy.configure` for global settings: `base_url`, `language`, `country_id`, `read_timeout`, `open_timeout`
|
|
10
|
+
- `Pratka::Speedy::Client` with username/password authentication
|
|
11
|
+
- Location lookups: `fetch_offices`, `fetch_cities`, `fetch_countries`, `fetch_complexes`, `fetch_streets`
|
|
12
|
+
- `calculate` returns price and delivery deadline for one or more services
|
|
13
|
+
- `create_shipment`
|
|
14
|
+
- `print_label` returns raw PDF or ZPL bytes
|
|
15
|
+
- `fetch_payment_details` returns shipment payouts for a date range
|
|
16
|
+
- `track` returns the operation history for up to 10 parcels
|
|
17
|
+
- Error classes under `Pratka::Speedy`: `TimeoutError`, `ConnectionError`, `HTTPError` (with `status` and `body`), `APIError` (with `code`, `context`, `id`)
|
|
18
|
+
- Validation of required params, which treats blank values as missing
|
|
19
|
+
- Supports Ruby 3.3+ and JRuby
|
|
20
|
+
|
|
21
|
+
[Unreleased]: https://github.com/tripplesteel/pratka/compare/v0.1.0...HEAD
|
|
22
|
+
[0.1.0]: https://github.com/tripplesteel/pratka/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stilqn Bairski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# Pratka
|
|
2
|
+
|
|
3
|
+
Ruby client for Bulgarian courier APIs. Supported couriers:
|
|
4
|
+
|
|
5
|
+
- [Econt](https://www.econt.com/developers/soap-json-api.html), [Econt API Models](https://ee.econt.com/services/Shipments/) (Work in progress)
|
|
6
|
+
- [Speedy](https://api.speedy.bg/api/docs/)
|
|
7
|
+
|
|
8
|
+
I built this because I mainly work on Ruby/JRuby on Rails e-shops that integrate with Speedy and Econt, and I kept copying the same API code from project to project. With Pratka, each project can call the courier APIs directly. I'm starting with the endpoints we use most. If you need one that isn't implemented yet, feel free to [open an issue](https://github.com/tripplesteel/pratka/issues).
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Requires Ruby 3.3 or newer
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "pratka"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
or
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle add pratka
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Speedy
|
|
25
|
+
|
|
26
|
+
### Setup
|
|
27
|
+
`Pratka::Speedy` works without any configuration. Add the gem, create a `Pratka::Speedy::Client` with your credentials, and you're ready. To override the defaults, set any of `base_url`, `language`, `country_id`, `read_timeout` and `open_timeout`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Pratka::Speedy.configure do |c|
|
|
31
|
+
c.base_url = ENV["SPEEDY_BASE_URL"] # Defaults to "https://api.speedy.bg/v1/"
|
|
32
|
+
c.language = ENV["SPEEDY_LANGUAGE"] # "BG" or "EN". Anything else falls back to "BG"
|
|
33
|
+
c.country_id = 100 # Defaults to 100 (Bulgaria)
|
|
34
|
+
c.read_timeout = 60 # Seconds. Defaults to 30. nil waits forever
|
|
35
|
+
c.open_timeout = 10 # Seconds. Defaults to 10. nil waits forever
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Client initialization
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
client = Pratka::Speedy::Client.new(
|
|
43
|
+
username: ENV["SPEEDY_USERNAME"],
|
|
44
|
+
password: ENV["SPEEDY_PASSWORD"],
|
|
45
|
+
language: "EN", # Optional. Defaults to the configured language
|
|
46
|
+
country_id: 100 # Optional. Defaults to the configured country_id
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Credentials go on the client, not the global configuration, so you can run several Speedy accounts side by side
|
|
51
|
+
|
|
52
|
+
### API methods
|
|
53
|
+
|
|
54
|
+
#### Fetch Speedy offices
|
|
55
|
+
|
|
56
|
+
It's scoped automatically to client's country
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
client.fetch_offices
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Allowed options:
|
|
63
|
+
- `site_id` - Site ID. Limits the search scope in the set of offices for specified site. If omitted - all country offices are searched
|
|
64
|
+
- `site_name` - Filters the results by office site name prefix or part of it
|
|
65
|
+
- `name` - Search term for office name. Filters the results by office name prefix or part of site name
|
|
66
|
+
- `limit` - The number of records to return in response. All records are returned if this parameter is omitted
|
|
67
|
+
- `office_type` - array of ["OFFICE", "APT"]
|
|
68
|
+
- `office_features` - array of ["CARD_PAYMENT", "CASH_PAYMENT", "DROP_OFF", "PICK_UP", "CARGO_TYPE_PARCEL", "CARGO_TYPE_PALLET", "CARGO_TYPE_TYRE"]
|
|
69
|
+
|
|
70
|
+
Returns the parsed JSON response. Values keep their JSON types, so IDs are `Integer`, coordinates are `Float` and flags are `true`/`false`
|
|
71
|
+
|
|
72
|
+
Example response:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
{"offices" =>
|
|
76
|
+
[{"id" => 1,
|
|
77
|
+
"name" => "ПЛОВДИВ – СКЛАД ЮГ",
|
|
78
|
+
"nameEn" => "PLOVDIV - WAREHOUSE SOUTH",
|
|
79
|
+
"siteId" => 56784,
|
|
80
|
+
"address" =>
|
|
81
|
+
{"countryId" => 100,
|
|
82
|
+
"siteId" => 56784,
|
|
83
|
+
"siteType" => "гр.",
|
|
84
|
+
"siteName" => "Пловдив",
|
|
85
|
+
"postCode" => "4000",
|
|
86
|
+
"streetId" => 10621,
|
|
87
|
+
"streetType" => "ул.",
|
|
88
|
+
"streetName" => "Кукленско шосе",
|
|
89
|
+
"streetNo" => "15",
|
|
90
|
+
"x" => 24.761268,
|
|
91
|
+
"y" => 42.120139,
|
|
92
|
+
"fullAddressString" => "гр. Пловдив ул. Кукленско шосе No 15",
|
|
93
|
+
"siteAddressString" => "гр. Пловдив",
|
|
94
|
+
"localAddressString" => "ул. Кукленско шосе No 15"},
|
|
95
|
+
"workingTimeFrom" => "08:30",
|
|
96
|
+
"workingTimeTo" => "19:30",
|
|
97
|
+
"workingTimeHalfFrom" => "08:30",
|
|
98
|
+
"workingTimeHalfTo" => "14:30",
|
|
99
|
+
"workingTimeDayOffFrom" => "00:00",
|
|
100
|
+
"workingTimeDayOffTo" => "00:00",
|
|
101
|
+
"sameDayDepartureCutoff" => "19:30",
|
|
102
|
+
"sameDayDepartureCutoffHalf" => "14:30",
|
|
103
|
+
"sameDayDepartureCutoffDayOff" => "19:00",
|
|
104
|
+
"maxParcelDimensions" => {"width" => 240, "height" => 240, "depth" => 600},
|
|
105
|
+
"maxParcelWeight" => 1200.0,
|
|
106
|
+
"type" => "OFFICE",
|
|
107
|
+
"nearbyOfficeId" => 836,
|
|
108
|
+
"workingTimeSchedule" =>
|
|
109
|
+
[{"date" => "2026-10-06",
|
|
110
|
+
"workingTimeFrom" => "08:30",
|
|
111
|
+
"workingTimeTo" => "19:30",
|
|
112
|
+
"sameDayDepartureCutoff" => "19:30",
|
|
113
|
+
"standardSchedule" => true}],
|
|
114
|
+
"validFrom" => "2000-01-01",
|
|
115
|
+
"validTo" => "3000-01-01",
|
|
116
|
+
"cargoTypesAllowed" => ["PALLET", "PARCEL", "TYRE"],
|
|
117
|
+
"pickUpAllowed" => true,
|
|
118
|
+
"dropOffAllowed" => true,
|
|
119
|
+
"routingInformation" =>
|
|
120
|
+
{"tour" => {"number" => 10706, "officeId" => 1, "ramp" => "07", "cell" => "06"},
|
|
121
|
+
"officeId" => 1,
|
|
122
|
+
"hubId" => 1,
|
|
123
|
+
"priority" => 2},
|
|
124
|
+
"cardPaymentAllowed" => true,
|
|
125
|
+
"cashPaymentAllowed" => true,
|
|
126
|
+
"palletOffice" => true
|
|
127
|
+
}]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
#### Fetch Speedy cities
|
|
132
|
+
```ruby
|
|
133
|
+
client.fetch_cities
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Returns the cities for the client's `country_id`, which defaults to the configured `country_id` (100, Bulgaria). Use a client with a different `country_id` to fetch another country's cities
|
|
137
|
+
|
|
138
|
+
Speedy serves this endpoint as CSV, so every value is a `String` and empty fields are `nil`. Convert IDs before comparing them with JSON responses. For example, an office's `"siteId" => 56784` matches a city's `"id" => "56784"`
|
|
139
|
+
|
|
140
|
+
Example response:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
[{"id" => "14",
|
|
144
|
+
"countryId" => "100",
|
|
145
|
+
"mainSiteId" => "0",
|
|
146
|
+
"type" => "с.",
|
|
147
|
+
"typeEn" => "s.",
|
|
148
|
+
"name" => "Абланица",
|
|
149
|
+
"nameEn" => "Ablanitsa",
|
|
150
|
+
"municipality" => "Хаджидимово",
|
|
151
|
+
"municipalityEn" => "Hadzhidimovo",
|
|
152
|
+
"region" => "Благоевград",
|
|
153
|
+
"regionEn" => "Blagoevgrad",
|
|
154
|
+
"postCode" => "2932",
|
|
155
|
+
"addressNomenclature" => "0",
|
|
156
|
+
"x" => "23.934398",
|
|
157
|
+
"y" => "41.536921",
|
|
158
|
+
"servingDays" => "1111100",
|
|
159
|
+
"servingOfficeId" => "130",
|
|
160
|
+
"servingHubOfficeId" => "13"
|
|
161
|
+
}]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### Fetch Speedy countries
|
|
165
|
+
```ruby
|
|
166
|
+
client.fetch_countries
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Speedy serves this endpoint as CSV, so every value is a `String` and empty fields are `nil`. Booleans come back as `"true"` and `"false"`
|
|
170
|
+
|
|
171
|
+
Example response:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
[{"id" => "36",
|
|
175
|
+
"name" => "Австралия",
|
|
176
|
+
"nameEn" => "Australia",
|
|
177
|
+
"isoAlpha2" => "AU",
|
|
178
|
+
"isoAlpha3" => "AUS",
|
|
179
|
+
"postCodeFormats" => "NNNN",
|
|
180
|
+
"requireState" => "false",
|
|
181
|
+
"addressType" => "2",
|
|
182
|
+
"currencyCode" => nil,
|
|
183
|
+
"defaultOfficeId" => "900",
|
|
184
|
+
"streetTypes" => nil,
|
|
185
|
+
"streetTypesEn" => nil,
|
|
186
|
+
"complexTypes" => nil,
|
|
187
|
+
"complexTypesEn" => nil,
|
|
188
|
+
"siteNomen" => "0"
|
|
189
|
+
}]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### Fetch complexes
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
client.fetch_complexes(site_id: 881)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Allowed options:
|
|
199
|
+
- `site_id` (Mandatory) - Site ID
|
|
200
|
+
- `name` - Search term for complex name. Filters the results by complex name prefix or part of complex name
|
|
201
|
+
|
|
202
|
+
Returns the parsed JSON response
|
|
203
|
+
|
|
204
|
+
Example response:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
{"complexes" =>
|
|
208
|
+
[{"id" => 16182,
|
|
209
|
+
"siteId" => 881,
|
|
210
|
+
"actualId" => 0,
|
|
211
|
+
"type" => "",
|
|
212
|
+
"typeEn" => "",
|
|
213
|
+
"name" => "Магерови колиби",
|
|
214
|
+
"nameEn" => "Magerovi kolibi"}]}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Fetch streets
|
|
218
|
+
```ruby
|
|
219
|
+
client.fetch_streets(site_id: 36124)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Allowed options:
|
|
223
|
+
- `site_id` (Mandatory) - Site ID
|
|
224
|
+
- `name` - Filters the results by street name
|
|
225
|
+
|
|
226
|
+
Returns the parsed JSON response
|
|
227
|
+
|
|
228
|
+
Example response:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
{"streets" =>
|
|
232
|
+
[{"id" => 156597,
|
|
233
|
+
"actualId" => 0,
|
|
234
|
+
"siteId" => 36124,
|
|
235
|
+
"type" => "ул.",
|
|
236
|
+
"typeEn" => "ul.",
|
|
237
|
+
"name" => "Алеко Константинов",
|
|
238
|
+
"nameEn" => "Aleko Konstantinov"}
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### Print label
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
client.print_label(paper_size: "A4", parcels: [{ parcel: { id: "123" } }])
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Allowed options:
|
|
250
|
+
- `paper_size` (Mandatory) - Paper size of the label
|
|
251
|
+
- `parcels` (Mandatory) - Array of hashes. Example: [ { parcel: { id: "speedy_tracking_number" } } ]
|
|
252
|
+
- `format` - Allowed values are `pdf` or `zpl`. Defaults to `pdf`
|
|
253
|
+
- `printer_name` - Name of the printer
|
|
254
|
+
- `dpi` - Allowed values are `dpi203` or `dpi300`. Defaults to `dpi203`
|
|
255
|
+
- `sender_copy` - Allowed values are `NONE`, `ON_SAME_PAGE`, `ON_SINGLE_PAGE`. Defaults to `NONE`
|
|
256
|
+
|
|
257
|
+
Returns raw PDF or ZPL bytes if the request is successful and the parcel exists
|
|
258
|
+
|
|
259
|
+
If the parcels don't exist in Speedy, `print_label` raises `Pratka::Speedy::Error` with the message `Speedy returned an empty label; check the parcel IDs`
|
|
260
|
+
|
|
261
|
+
If you send empty parcels, `print_label` raises `ArgumentError` with the message `Missing params: parcels`
|
|
262
|
+
|
|
263
|
+
#### Fetch payment details
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
client.fetch_payment_details(from_date: Time.new(2026, 10, 1), to_date: Time.now, include_details: true)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Allowed options:
|
|
270
|
+
- `from_date` (Mandatory) - Start of the period
|
|
271
|
+
- `to_date` (Mandatory) - End of the period
|
|
272
|
+
- `include_details` - Include per-shipment payout details. Defaults to `false`
|
|
273
|
+
|
|
274
|
+
Dates accept `Date`, `DateTime`, `Time` or a string. The client sends `Date`, `DateTime` and `Time` as `yyyy-MM-dd'T'HH:mm:ssZ`, for example `"2026-10-01T09:00:00+0300"`. A `Date` has no time or zone, so it becomes midnight UTC. Strings are sent as they are
|
|
275
|
+
|
|
276
|
+
Returns the parsed JSON response. `details` is filled only when `include_details` is `true`
|
|
277
|
+
|
|
278
|
+
Example response:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
{"payouts" =>
|
|
282
|
+
[{"date" => "2026-10-03",
|
|
283
|
+
"docId" => 123456789,
|
|
284
|
+
"docType" => "POSTAL_MONEY_TRANSFER", # or "CASH"
|
|
285
|
+
"paymentType" => "BANK", # or "CASH"
|
|
286
|
+
"payee" => "Example Ltd",
|
|
287
|
+
"currency" => "BGN",
|
|
288
|
+
"amount" => 59.9,
|
|
289
|
+
"details" =>
|
|
290
|
+
[{"lineNo" => 1,
|
|
291
|
+
"shipmentId" => "61234567890",
|
|
292
|
+
"pickupDate" => "2026-09-30",
|
|
293
|
+
"primaryShipmentPickupDate" => nil,
|
|
294
|
+
"deliveryDate" => "2026-10-01",
|
|
295
|
+
"sender" => "Example Ltd",
|
|
296
|
+
"recipient" => "Ivan Ivanov",
|
|
297
|
+
"note" => "",
|
|
298
|
+
"ref1" => "ORDER-1001",
|
|
299
|
+
"ref2" => "",
|
|
300
|
+
"currency" => "BGN",
|
|
301
|
+
"order" => 1001,
|
|
302
|
+
"amount" => 59.9}]
|
|
303
|
+
}]
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
#### Create shipment
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
client.create_shipment(
|
|
311
|
+
recipient: {
|
|
312
|
+
phone1: { number: "0899445566" },
|
|
313
|
+
clientName: "Ivan Ivanov",
|
|
314
|
+
privatePerson: true,
|
|
315
|
+
pickupOfficeId: 77
|
|
316
|
+
},
|
|
317
|
+
service: { serviceId: 505, autoAdjustPickupDate: true },
|
|
318
|
+
content: { parcelsCount: 1, totalWeight: 0.6, contents: "Mobile phone", package: "BOX" },
|
|
319
|
+
payment: { courierServicePayer: "RECIPIENT" },
|
|
320
|
+
shipment_note: "Fragile"
|
|
321
|
+
)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Allowed options:
|
|
325
|
+
- `recipient` (Mandatory) - [ShipmentRecipient](https://api.speedy.bg/api/docs/#href-ds-shipment-recipient). The recipient and the delivery place
|
|
326
|
+
- `service` (Mandatory) - [ShipmentService](https://api.speedy.bg/api/docs/#href-ds-shipment-service). The service level, pickup date and additional services
|
|
327
|
+
- `content` (Mandatory) - [ShipmentContent](https://api.speedy.bg/api/docs/#href-ds-shipment-content). Parcel count, weight, size and contents
|
|
328
|
+
- `payment` (Mandatory) - [ShipmentPayment](https://api.speedy.bg/api/docs/#href-ds-shipment-payment). Who pays for what
|
|
329
|
+
- `sender` - [ShipmentSender](https://api.speedy.bg/api/docs/#href-ds-shipment-sender). The sender and the pickup place. If omitted, the logged-in user is the sender
|
|
330
|
+
- `shipment_note` - Customer's note for the shipment
|
|
331
|
+
|
|
332
|
+
Returns the parsed JSON response. Use the parcel `id` values with `print_label`. Speedy returns `price` only if your account can view shipment amounts, and `deliveryDeadline` only when it knows one
|
|
333
|
+
Example response:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
{"id" => "299999990",
|
|
337
|
+
"parcels" => [{"seqNo" => 1, "id" => "299999990"}],
|
|
338
|
+
"pickupDate" => "2026-10-07",
|
|
339
|
+
"price" =>
|
|
340
|
+
{"amount" => 5.5,
|
|
341
|
+
"vat" => 1.1,
|
|
342
|
+
"total" => 6.6,
|
|
343
|
+
"currency" => "BGN"},
|
|
344
|
+
"deliveryDeadline" => "2026-10-08T19:00:00+03:00"
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
#### Calculate
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
client.calculate(
|
|
352
|
+
recipient: { privatePerson: true, pickupOfficeId: 77 },
|
|
353
|
+
service: { serviceIds: [505, 412], autoAdjustPickupDate: true },
|
|
354
|
+
content: { parcelsCount: 1, totalWeight: 0.6 },
|
|
355
|
+
payment: { courierServicePayer: "RECIPIENT" }
|
|
356
|
+
)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Allowed options:
|
|
360
|
+
- `recipient` (Mandatory) - [CalculationRecipient](https://api.speedy.bg/api/docs/#href-ds-calculation-recipient). The delivery place
|
|
361
|
+
- `service` (Mandatory) - [CalculationService](https://api.speedy.bg/api/docs/#href-ds-calculation-service). The service IDs to price, the pickup date and additional services
|
|
362
|
+
- `content` (Mandatory) - [CalculationContent](https://api.speedy.bg/api/docs/#href-ds-calculation-content). Parcel count and weight, or a list of parcels
|
|
363
|
+
- `payment` (Mandatory) - [ShipmentPayment](https://api.speedy.bg/api/docs/#href-ds-shipment-payment). Who pays for what
|
|
364
|
+
- `sender` - [CalculationSender](https://api.speedy.bg/api/docs/#href-ds-calculation-sender). The pickup place. If omitted, the logged-in user's location is used
|
|
365
|
+
|
|
366
|
+
Returns the parsed JSON response with one calculation per service ID. Speedy returns `price` only if your account can view shipment amounts, and `deliveryDeadline` only when it knows one
|
|
367
|
+
|
|
368
|
+
A service Speedy can't price for this destination comes back with its own `error` object instead of a price. `calculate` doesn't raise for it, so check each calculation
|
|
369
|
+
|
|
370
|
+
Example response:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
{"calculations" =>
|
|
374
|
+
[{"serviceId" => 505,
|
|
375
|
+
"additionalServices" => {},
|
|
376
|
+
"price" =>
|
|
377
|
+
{"amount" => 5.5,
|
|
378
|
+
"vat" => 1.1,
|
|
379
|
+
"total" => 6.6,
|
|
380
|
+
"currency" => "BGN"},
|
|
381
|
+
"pickupDate" => "2026-10-07",
|
|
382
|
+
"deliveryDeadline" => "2026-10-08T19:00:00+03:00"},
|
|
383
|
+
{"serviceId" => 412,
|
|
384
|
+
"error" =>
|
|
385
|
+
{"context" => "service.serviceIds",
|
|
386
|
+
"message" => "Service is not allowed for this destination",
|
|
387
|
+
"id" => "EE-1234",
|
|
388
|
+
"code" => 1}}]
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### Track
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
client.track(parcels: [{ id: "299999990" }, { ref: "ORDER-1001" }], last_operation_only: true)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Allowed options:
|
|
399
|
+
- `parcels` (Mandatory) - Array of [TrackShipmentParcelRef](https://api.speedy.bg/api/docs/#href-ds-track-shipment-parcel-ref), at most 10. Each parcel needs one of `id`, `ref`, `fullBarcode` or `externalCarrierParcelNumber`. `ref` matches the `ref1` and `ref2` fields of a parcel or shipment
|
|
400
|
+
- `last_operation_only` - Return only the latest operation per parcel. Defaults to `false`
|
|
401
|
+
|
|
402
|
+
Returns the parsed JSON response with one tracked parcel per matched parcel. A `ref` can match up to 10 parcels. Operation codes are listed in [Appendix 1](https://api.speedy.bg/api/docs/#href-appendix1-track-and-trace-codes) of the Speedy docs
|
|
403
|
+
|
|
404
|
+
A parcel Speedy can't find comes back with its own `error` object instead of operations. `track` doesn't raise for it, so check each parcel
|
|
405
|
+
|
|
406
|
+
Speedy asks clients to send at most 10 parcels per request and plans to enforce that limit. `track` enforces it already, so if you send more than 10 parcels it raises `ArgumentError` with the message `Speedy tracks at most 10 parcels per call`. Split larger lists into batches, for example with `each_slice(10)`
|
|
407
|
+
|
|
408
|
+
Example response:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
{"parcels" =>
|
|
412
|
+
[{"parcelId" => "299999990",
|
|
413
|
+
"operations" =>
|
|
414
|
+
[{"dateTime" => "2026-10-08T11:42:10+0300",
|
|
415
|
+
"operationCode" => -14,
|
|
416
|
+
"description" => "Delivered",
|
|
417
|
+
"place" => "SOFIA",
|
|
418
|
+
"consignee" => "Ivan Ivanov"}]}]
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Errors
|
|
423
|
+
|
|
424
|
+
Every network and API failure raises a subclass of `Pratka::Speedy::Error`, which inherits from `Pratka::Error`
|
|
425
|
+
|
|
426
|
+
| Error | Raised when | Extra attributes |
|
|
427
|
+
| --- | --- | --- |
|
|
428
|
+
| `Pratka::Speedy::TimeoutError` | The connection or read times out | |
|
|
429
|
+
| `Pratka::Speedy::ConnectionError` | DNS, socket or SSL failure | |
|
|
430
|
+
| `Pratka::Speedy::HTTPError` | Speedy returns a non-2xx status | `status`, `body` |
|
|
431
|
+
| `Pratka::Speedy::APIError` | Speedy returns 2xx with an `error` object in the body | `code`, `context`, `id` |
|
|
432
|
+
| `Pratka::Speedy::Error` | The response is malformed, has invalid JSON, or has an unexpected format | |
|
|
433
|
+
|
|
434
|
+
Bad arguments raise `ArgumentError` before any request is sent, for example an unknown option or a missing `site_id`
|
|
435
|
+
|
|
436
|
+
```ruby
|
|
437
|
+
begin
|
|
438
|
+
client.fetch_streets(site_id: 36124, name: "Алеко")
|
|
439
|
+
rescue Pratka::Speedy::APIError => e
|
|
440
|
+
logger.warn("Speedy rejected the request: #{e.message} (code #{e.code})")
|
|
441
|
+
rescue Pratka::Speedy::TimeoutError, Pratka::Speedy::ConnectionError
|
|
442
|
+
retry_later
|
|
443
|
+
rescue Pratka::Speedy::Error => e
|
|
444
|
+
logger.error("Speedy failed: #{e.message}")
|
|
445
|
+
end
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## Development
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
bin/setup # install dependencies
|
|
452
|
+
bundle exec rake # run specs and RuboCop
|
|
453
|
+
bin/console # IRB session with the gem loaded
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
To release a new version, update `lib/pratka/version.rb` and `CHANGELOG.md`, then run `bundle exec rake release`. That task tags the commit, pushes the tag and publishes the gem to [rubygems.org](https://rubygems.org)
|
|
457
|
+
|
|
458
|
+
## License
|
|
459
|
+
|
|
460
|
+
[MIT](LICENSE.txt)
|
data/lib/pratka/error.rb
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Pratka
|
|
6
|
+
module Speedy
|
|
7
|
+
# Authenticated client for the Speedy courier API
|
|
8
|
+
class Client
|
|
9
|
+
OFFICES_ENDPOINT = "location/office"
|
|
10
|
+
OFFICES_PARAMS = {
|
|
11
|
+
site_id: :siteId,
|
|
12
|
+
site_name: :siteName,
|
|
13
|
+
name: :name,
|
|
14
|
+
limit: :limit,
|
|
15
|
+
office_type: :officeType,
|
|
16
|
+
office_features: :officeFeatures
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
CITIES_ENDPOINT = "location/site/csv" # Speedy wants to add Country ID at the end of the endpoint. Example location/site/csv/100
|
|
20
|
+
COUNTRIES_ENDPOINT = "location/country/csv"
|
|
21
|
+
|
|
22
|
+
COMPLEX_ENDPOINT = "location/complex"
|
|
23
|
+
COMPLEX_PARAMS = { site_id: :siteId, name: :name }.freeze
|
|
24
|
+
|
|
25
|
+
STREET_ENDPOINT = "location/street"
|
|
26
|
+
STREET_PARAMS = { site_id: :siteId, name: :name }.freeze
|
|
27
|
+
|
|
28
|
+
PRINT_ENDPOINT = "print"
|
|
29
|
+
PRINT_PARAMS = {
|
|
30
|
+
format: :format,
|
|
31
|
+
paper_size: :paperSize,
|
|
32
|
+
printer_name: :printerName,
|
|
33
|
+
dpi: :dpi,
|
|
34
|
+
sender_copy: :additionalWaybillSenderCopy,
|
|
35
|
+
parcels: :parcels
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
PAYMENTS_ENDPOINT = "payments"
|
|
39
|
+
PAYMENTS_PARAMS = {
|
|
40
|
+
from_date: :fromDate,
|
|
41
|
+
to_date: :toDate,
|
|
42
|
+
include_details: :includeDetails
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
CREATE_SHIPMENT_ENDPOINT = "shipment"
|
|
46
|
+
SHIPMENT_PARAMS = {
|
|
47
|
+
sender: :sender,
|
|
48
|
+
recipient: :recipient,
|
|
49
|
+
service: :service,
|
|
50
|
+
content: :content,
|
|
51
|
+
payment: :payment,
|
|
52
|
+
shipment_note: :shipmentNote
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
CALCULATION_ENDPOINT = "calculate"
|
|
56
|
+
CALCULATION_PARAMS = {
|
|
57
|
+
sender: :sender,
|
|
58
|
+
recipient: :recipient,
|
|
59
|
+
service: :service,
|
|
60
|
+
content: :content,
|
|
61
|
+
payment: :payment
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
TRACK_ENDPOINT = "track"
|
|
65
|
+
TRACK_PARAMS = { parcels: :parcels, last_operation_only: :lastOperationOnly }.freeze
|
|
66
|
+
|
|
67
|
+
# Speedy recommends 10 parcels per request and plans to enforce it
|
|
68
|
+
|
|
69
|
+
TRACK_MAX_PARCELS = 10
|
|
70
|
+
|
|
71
|
+
def initialize(username:, password:, language: nil, country_id: Speedy.configuration.country_id)
|
|
72
|
+
@username = username
|
|
73
|
+
@password = password
|
|
74
|
+
@language = decide_language(language)
|
|
75
|
+
@country_id = country_id
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fetch all offices from Speedy
|
|
79
|
+
# Allowed params:
|
|
80
|
+
# site_id - Site id. Limits the search scope in the set of offices for specified site. If omitted - all country offices are searched
|
|
81
|
+
# site_name - Filters the results by office site name prefix or part of it
|
|
82
|
+
# name - Search term for office name. Filters the results by office name prefix or part of site name
|
|
83
|
+
# limit - The number of records to return in response. All records are returned if this parameter is omitted
|
|
84
|
+
# office_type - array of ["OFFICE", "APT"]
|
|
85
|
+
# office_features - array of ["CARD_PAYMENT", "CASH_PAYMENT", "DROP_OFF", "PICK_UP", "CARGO_TYPE_PARCEL", "CARGO_TYPE_PALLET", "CARGO_TYPE_TYRE"][]
|
|
86
|
+
# Returns the parsed JSON response
|
|
87
|
+
def fetch_offices(**options)
|
|
88
|
+
call(OFFICES_ENDPOINT, map_params(options, OFFICES_PARAMS).merge(countryId: @country_id))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fetch all cities available in Speedy
|
|
92
|
+
# Returns the parsed array of hashes
|
|
93
|
+
def fetch_cities
|
|
94
|
+
fetch_csv("#{CITIES_ENDPOINT}/#{@country_id}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Fetch all countries available in Speedy
|
|
98
|
+
# Returns the parsed array of hashes
|
|
99
|
+
def fetch_countries
|
|
100
|
+
fetch_csv(COUNTRIES_ENDPOINT)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# List all complexes in Speedy
|
|
104
|
+
# Allowed params:
|
|
105
|
+
# site_id - (Mandatory)
|
|
106
|
+
# name - Search term for complex name. Filters the results by complex name prefix or part of complex name
|
|
107
|
+
def fetch_complexes(**options)
|
|
108
|
+
call(COMPLEX_ENDPOINT, map_params(options, COMPLEX_PARAMS, required: [:site_id]))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# List all streets in Speedy
|
|
112
|
+
# Allowed params:
|
|
113
|
+
# site_id - (Mandatory)
|
|
114
|
+
# name - Search term for street name. Filters the results by street name prefix or part of street name
|
|
115
|
+
def fetch_streets(**options)
|
|
116
|
+
call(STREET_ENDPOINT, map_params(options, STREET_PARAMS, required: [:site_id]))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Print label for your parcels. Returns raw PDF or ZPL bytes
|
|
120
|
+
# Allowed params:
|
|
121
|
+
# paper_size - (Mandatory)
|
|
122
|
+
# parcels - (Mandatory) - Array of hashes. Example: [ { "parcel" => { id: 'speedy_tracking_number' } } ]
|
|
123
|
+
# format - Allowed values are `pdf` or `zpl`. Default one is `pdf`
|
|
124
|
+
# printer_name
|
|
125
|
+
# dpi - Allowed values are `dpi203` or `dpi300`. Default one is `dpi203`
|
|
126
|
+
# sender_copy - Allowed values are `NONE`, `ON_SAME_PAGE`, `ON_SINGLE_PAGE`. Default one is `NONE`
|
|
127
|
+
def print_label(**options)
|
|
128
|
+
label = call(PRINT_ENDPOINT, map_params(options, PRINT_PARAMS, required: %i[paper_size parcels]))
|
|
129
|
+
raise Error, "Speedy returned an empty label; check the parcel IDs" unless label.is_a?(String) && !label.empty?
|
|
130
|
+
|
|
131
|
+
# HTTP labels every body UTF-8; labels are binary
|
|
132
|
+
|
|
133
|
+
label.b
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fetch shipment payouts for a period
|
|
137
|
+
# Allowed params:
|
|
138
|
+
# from_date - (Mandatory) Date, DateTime, Time or a "yyyy-MM-ddTHH:mm:ss+zzzz" string
|
|
139
|
+
# to_date - (Mandatory) Date, DateTime, Time or a "yyyy-MM-ddTHH:mm:ss+zzzz" string
|
|
140
|
+
# include_details - Include per-shipment payout details. Default one is false
|
|
141
|
+
def fetch_payment_details(**options)
|
|
142
|
+
params = map_params(options, PAYMENTS_PARAMS, required: %i[from_date to_date])
|
|
143
|
+
params[:fromDate] = format_datetime(params[:fromDate])
|
|
144
|
+
params[:toDate] = format_datetime(params[:toDate])
|
|
145
|
+
|
|
146
|
+
call(PAYMENTS_ENDPOINT, params)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Create shipment
|
|
150
|
+
# [Create Shipment Request (CreateShipmentRequest)](https://api.speedy.bg/api/docs/#href-create-shipment-req)
|
|
151
|
+
# Allowed params:
|
|
152
|
+
# sender - ShipmentSender - Defines the sender of the shipment and shipment's pickup place. If not specified, the logged user is considered as a sender
|
|
153
|
+
# recipient (Mandatory) - ShipmentRecipient - Defines the recipient of the shipment and shipment’s delivery place
|
|
154
|
+
# service (Mandatory) - ShipmentService - Defines shipment service level agreement
|
|
155
|
+
# content (Mandatory) - ShipmentContent - Defines shipment’s content - number of parcels, weight, size, etc
|
|
156
|
+
# payment (Mandatory) - ShipmentPayment - Defines who-pays-what in shipment and other payment parameters
|
|
157
|
+
# shipment_note - String - Customer’s note associated with the shipment
|
|
158
|
+
def create_shipment(**options)
|
|
159
|
+
call(CREATE_SHIPMENT_ENDPOINT, map_params(options, SHIPMENT_PARAMS, required: %i[recipient service content payment]))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Calculate shipment price and delivery deadline for one or more services
|
|
163
|
+
# [Calculation Request (CalculationRequest)](https://api.speedy.bg/api/docs/#href-calculation-req)
|
|
164
|
+
# Allowed params:
|
|
165
|
+
# sender - CalculationSender - Defines the pickup place. If not specified, the logged user's location is used
|
|
166
|
+
# recipient (Mandatory) - CalculationRecipient - Defines the delivery place
|
|
167
|
+
# service (Mandatory) - CalculationService - Defines the service ids to price and the pickup date
|
|
168
|
+
# content (Mandatory) - CalculationContent - Defines number of parcels, weight, etc
|
|
169
|
+
# payment (Mandatory) - ShipmentPayment - Defines who-pays-what in shipment
|
|
170
|
+
# Returns one calculation per service id. Each calculation carries its own error if Speedy can't price that service
|
|
171
|
+
def calculate(**options)
|
|
172
|
+
call(CALCULATION_ENDPOINT, map_params(options, CALCULATION_PARAMS, required: %i[recipient service content payment]))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Track parcels and return their operation history
|
|
176
|
+
# [Track Request (TrackRequest)](https://api.speedy.bg/api/docs/#href-track-req)
|
|
177
|
+
# Allowed params:
|
|
178
|
+
# parcels (Mandatory) - TrackShipmentParcelRef[] - Up to 10 parcels. Each needs one of id, ref, fullBarcode or externalCarrierParcelNumber
|
|
179
|
+
# last_operation_only - Boolean - Return only the latest operation per parcel. Default one is false
|
|
180
|
+
# Returns one tracked parcel per matched parcel. A ref can match up to 10. Each tracked parcel carries its own error if Speedy can't track it
|
|
181
|
+
def track(**options)
|
|
182
|
+
params = map_params(options, TRACK_PARAMS, required: [:parcels])
|
|
183
|
+
raise ArgumentError, "Speedy tracks at most #{TRACK_MAX_PARCELS} parcels per call" if params[:parcels].size > TRACK_MAX_PARCELS
|
|
184
|
+
|
|
185
|
+
call(TRACK_ENDPOINT, params)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def call(endpoint, data = {})
|
|
191
|
+
http.call(endpoint, data)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def map_params(options, mapping, required: [])
|
|
195
|
+
unknown = options.keys - mapping.keys
|
|
196
|
+
raise ArgumentError, "Unknown params: #{unknown.join(", ")}" if unknown.any?
|
|
197
|
+
|
|
198
|
+
missing = required.select { |key| blank?(options[key]) }
|
|
199
|
+
raise ArgumentError, "Missing params: #{missing.join(", ")}" if missing.any?
|
|
200
|
+
|
|
201
|
+
options.transform_keys(mapping)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def blank?(value)
|
|
205
|
+
value = value.strip if value.respond_to?(:strip)
|
|
206
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Speedy wants yyyy-MM-dd'T'HH:mm:ssZ, where Z is an offset like +0300. A Date becomes midnight UTC
|
|
210
|
+
def format_datetime(value)
|
|
211
|
+
value.respond_to?(:strftime) ? value.strftime("%Y-%m-%dT%H:%M:%S%z") : value
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fetch_csv(endpoint)
|
|
215
|
+
result = call(endpoint)
|
|
216
|
+
raise Error, "Expected CSV from #{endpoint}, got #{result.class}" unless result.is_a?(String)
|
|
217
|
+
|
|
218
|
+
CSV.parse(result.delete_prefix("").strip, headers: true).map(&:to_h)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def http
|
|
222
|
+
@http ||= HTTP.new(@username, @password, @language)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def decide_language(language)
|
|
226
|
+
return language if ["BG", "EN"].include?(language)
|
|
227
|
+
|
|
228
|
+
Speedy.configuration.language
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pratka
|
|
4
|
+
module Speedy
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_writer :base_url, :language, :country_id
|
|
7
|
+
attr_accessor :read_timeout, :open_timeout
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.speedy.bg/v1/"
|
|
10
|
+
DEFAULT_LANGUAGE = "BG"
|
|
11
|
+
DEFAULT_COUNTRY_ID = 100
|
|
12
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@base_url = DEFAULT_BASE_URL
|
|
17
|
+
@language = DEFAULT_LANGUAGE
|
|
18
|
+
@country_id = DEFAULT_COUNTRY_ID
|
|
19
|
+
@read_timeout = DEFAULT_READ_TIMEOUT
|
|
20
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def base_url
|
|
24
|
+
Util::StringHelper.nil_or_value(@base_url) || DEFAULT_BASE_URL
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def language
|
|
28
|
+
return @language if ["BG", "EN"].include?(@language)
|
|
29
|
+
|
|
30
|
+
DEFAULT_LANGUAGE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def country_id
|
|
34
|
+
Util::StringHelper.nil_or_value(@country_id) || DEFAULT_COUNTRY_ID
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
|
|
5
|
+
module Pratka
|
|
6
|
+
module Speedy
|
|
7
|
+
class Error < Pratka::Error; end
|
|
8
|
+
class TimeoutError < Error; end
|
|
9
|
+
class ConnectionError < Error; end
|
|
10
|
+
|
|
11
|
+
class HTTPError < Error
|
|
12
|
+
attr_reader :status, :body
|
|
13
|
+
|
|
14
|
+
def initialize(status, body)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
super(build_message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# The body often holds the only explanation, e.g. a request parsing error on a 400
|
|
23
|
+
def build_message
|
|
24
|
+
detail = @body.to_s.gsub(/\s+/, " ").strip
|
|
25
|
+
return "Speedy returned HTTP #{@status}" if detail.empty?
|
|
26
|
+
|
|
27
|
+
detail = "#{detail[0, 200]}..." if detail.length > 200
|
|
28
|
+
"Speedy returned HTTP #{@status}: #{detail}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class APIError < Error
|
|
33
|
+
attr_reader :code, :context, :id
|
|
34
|
+
|
|
35
|
+
def initialize(error)
|
|
36
|
+
error = { "message" => error.to_s } unless error.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
@code = error["code"]
|
|
39
|
+
@context = error["context"]
|
|
40
|
+
@id = error["id"]
|
|
41
|
+
super(error["message"] || "Speedy API error")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Pratka
|
|
7
|
+
module Speedy
|
|
8
|
+
# Net::HTTP class to handle requests to the Speedy API
|
|
9
|
+
class HTTP
|
|
10
|
+
def initialize(username, password, language)
|
|
11
|
+
@username = username
|
|
12
|
+
@password = password
|
|
13
|
+
@language = language
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Each call opens a fresh connection and closes it when the block exits
|
|
17
|
+
def call(endpoint, data)
|
|
18
|
+
response = Net::HTTP.start(base_uri.host, base_uri.port, **connection_options) do |http|
|
|
19
|
+
http.request(build_request(endpoint, data))
|
|
20
|
+
end
|
|
21
|
+
body = (+response.body.to_s).force_encoding(Encoding::UTF_8)
|
|
22
|
+
|
|
23
|
+
raise HTTPError.new(response.code.to_i, body) unless response.is_a?(Net::HTTPSuccess)
|
|
24
|
+
return body unless json?(response)
|
|
25
|
+
|
|
26
|
+
parsed = JSON.parse(body)
|
|
27
|
+
raise APIError.new(parsed["error"]) if parsed.is_a?(Hash) && parsed["error"]
|
|
28
|
+
|
|
29
|
+
parsed
|
|
30
|
+
rescue Timeout::Error => e
|
|
31
|
+
raise TimeoutError, e.message
|
|
32
|
+
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
|
|
33
|
+
raise ConnectionError, e.message
|
|
34
|
+
rescue Net::HTTPBadResponse, Zlib::Error => e
|
|
35
|
+
raise Error, "Malformed response from Speedy: #{e.message}"
|
|
36
|
+
rescue JSON::ParserError => e
|
|
37
|
+
raise Error, "Invalid JSON from Speedy: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def json?(response)
|
|
43
|
+
response.content_type.to_s.include?("json")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Speedy wants credentials in its data payload
|
|
47
|
+
def data_with_credentials(data)
|
|
48
|
+
{ userName: @username, password: @password, language: @language }.merge(data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def base_uri
|
|
52
|
+
@base_uri ||= URI(safe_base_url)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def safe_base_url
|
|
56
|
+
url = Speedy.configuration.base_url
|
|
57
|
+
url.end_with?("/") ? url : "#{url}/"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def connection_options
|
|
61
|
+
{
|
|
62
|
+
use_ssl: base_uri.scheme == "https",
|
|
63
|
+
read_timeout: Speedy.configuration.read_timeout,
|
|
64
|
+
open_timeout: Speedy.configuration.open_timeout
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_request(endpoint, data)
|
|
69
|
+
req = Net::HTTP::Post.new(base_uri.path + endpoint, "Content-Type" => "application/json")
|
|
70
|
+
req.body = data_with_credentials(data).to_json
|
|
71
|
+
|
|
72
|
+
req
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "speedy/errors"
|
|
4
|
+
require_relative "speedy/configuration"
|
|
5
|
+
require_relative "speedy/client"
|
|
6
|
+
require_relative "speedy/http"
|
|
7
|
+
|
|
8
|
+
module Pratka
|
|
9
|
+
module Speedy
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield configuration
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pratka
|
|
4
|
+
module Util
|
|
5
|
+
module StringHelper
|
|
6
|
+
class << self
|
|
7
|
+
def nil_or_value(value)
|
|
8
|
+
(value.nil? || value.to_s.strip.empty?) ? nil : value
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def present?(value)
|
|
12
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/pratka.rb
ADDED
data/sig/pratka.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pratka
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stilqn Bairski
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: csv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.3'
|
|
26
|
+
description: One Ruby interface for shipping, tracking and office lookup across Bulgarian
|
|
27
|
+
couriers.
|
|
28
|
+
email:
|
|
29
|
+
- steelbairski@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/pratka.rb
|
|
38
|
+
- lib/pratka/error.rb
|
|
39
|
+
- lib/pratka/speedy.rb
|
|
40
|
+
- lib/pratka/speedy/client.rb
|
|
41
|
+
- lib/pratka/speedy/configuration.rb
|
|
42
|
+
- lib/pratka/speedy/errors.rb
|
|
43
|
+
- lib/pratka/speedy/http.rb
|
|
44
|
+
- lib/pratka/util/string_helper.rb
|
|
45
|
+
- lib/pratka/version.rb
|
|
46
|
+
- sig/pratka.rbs
|
|
47
|
+
homepage: https://github.com/tripplesteel/pratka
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
allowed_push_host: https://rubygems.org
|
|
52
|
+
homepage_uri: https://github.com/tripplesteel/pratka
|
|
53
|
+
changelog_uri: https://github.com/tripplesteel/pratka/blob/main/CHANGELOG.md
|
|
54
|
+
bug_tracker_uri: https://github.com/tripplesteel/pratka/issues
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 3.3.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 4.0.22
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Ruby client for Bulgarian courier APIs (Speedy).
|
|
73
|
+
test_files: []
|