sendcloud_api 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 +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +286 -0
- data/lib/sendcloud_api/client.rb +76 -0
- data/lib/sendcloud_api/connection.rb +191 -0
- data/lib/sendcloud_api/errors.rb +47 -0
- data/lib/sendcloud_api/pagination.rb +35 -0
- data/lib/sendcloud_api/redactor.rb +93 -0
- data/lib/sendcloud_api/resources/base.rb +51 -0
- data/lib/sendcloud_api/resources/contracts.rb +35 -0
- data/lib/sendcloud_api/resources/integrations.rb +16 -0
- data/lib/sendcloud_api/resources/invoice_items.rb +13 -0
- data/lib/sendcloud_api/resources/parcel_documents.rb +16 -0
- data/lib/sendcloud_api/resources/parcel_tracking.rb +11 -0
- data/lib/sendcloud_api/resources/returns.rb +31 -0
- data/lib/sendcloud_api/resources/shipping_options.rb +12 -0
- data/lib/sendcloud_api/resources/user.rb +11 -0
- data/lib/sendcloud_api/response.rb +30 -0
- data/lib/sendcloud_api/version.rb +5 -0
- data/lib/sendcloud_api/webhook_signature.rb +20 -0
- data/lib/sendcloud_api.rb +22 -0
- metadata +164 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 796c66cfdea4b2bec78d7802df95083b114c53874570e6ac1ca56f1aab2ce6d9
|
|
4
|
+
data.tar.gz: d5079d7779398fd0e3457e7bf3cb4066393ea1c99390c43962f4bec5a4303f00
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 33362f6704bdf4672d035f81d52b9e25672edb92c5100f8932a0e03f9b6e76156889b087152aab54f06c5bda76c9524fae0c51570057963fcc33b7aeeb845b17
|
|
7
|
+
data.tar.gz: 728cd94df4f90b37106dd5cd247fce3a0445c56514ca790b46de3a342f886c40ac09504d117a5be3850cfef44c4a3bf3186b18e461ce41cc0e4e56585d628f9a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-10-20
|
|
4
|
+
|
|
5
|
+
- Add resources for returns (create, validate, retrieve, list, cancel), shipping option search, parcel documents, parcel tracking, contracts (list, create, retrieve, update, delete, schemas), integrations (retrieve, update), account metadata, and invoice items.
|
|
6
|
+
- Add `SendcloudAPI::Pagination.next_params`, which carries the caller's filters to the next page for both Sendcloud cursor styles, and `SendcloudAPI::WebhookSignature.verify` for signed webhooks.
|
|
7
|
+
- Reject unknown list filters before sending, because Sendcloud ignores them and returns every record. Reject `.` and `..` as ids.
|
|
8
|
+
- Add the client foundation for Sendcloud API v3 with HTTP Basic authentication and configurable timeouts.
|
|
9
|
+
- Return JSON, or caller-requested PDF, PNG, and ZPL documents, with typed errors and uncertain write outcomes, without automatic retries. An empty or mismatched document raises `ParseError`. Error messages include Sendcloud's error text, sent as a list or a single string, and leave out a `code` equal to the HTTP status.
|
|
10
|
+
- Remove credentials from responses and errors without changing field names, identifiers, ordinary text in successful JSON, or document bytes. Errors remove every known credential, however short.
|
|
11
|
+
- Reject a path that starts with `/` or a scheme, or has a `.` or `..` segment, before sending, so requests stay under the base URL.
|
|
12
|
+
- Add RSpec, Standard, Ruby 3.3 and 3.4 CI, and RubyGems OIDC publishing from a job that runs no repository code.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PostCo
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Sendcloud API
|
|
2
|
+
|
|
3
|
+
`sendcloud_api` is a Ruby client for the [Sendcloud API v3](https://sendcloud.dev/api/v3).
|
|
4
|
+
Each call sends one HTTP request without automatic retries. The caller owns idempotency, recovery, and business policy.
|
|
5
|
+
|
|
6
|
+
Version `0.1.0` adds resources for returns, shipping options, parcel documents and tracking, contracts, integrations, account metadata, invoice items, and webhook signatures.
|
|
7
|
+
Ruby 3.3 or newer is required.
|
|
8
|
+
|
|
9
|
+
## Authentication
|
|
10
|
+
|
|
11
|
+
Sendcloud uses the public key as the HTTP Basic username and the secret key as the password.
|
|
12
|
+
See [Sendcloud authentication](https://sendcloud.dev/docs/getting-started/authentication/).
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
client = SendcloudAPI::Client.new(
|
|
16
|
+
public_key: ENV.fetch("SENDCLOUD_PUBLIC_KEY"),
|
|
17
|
+
secret_key: ENV.fetch("SENDCLOUD_SECRET_KEY")
|
|
18
|
+
)
|
|
19
|
+
response = client.connection.get("user/auth/metadata")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Both credentials must be non-empty Strings. Configuration belongs to each client instance.
|
|
23
|
+
The default base URL is `https://panel.sendcloud.sc/api/v3/`, the open timeout is 5 seconds, and the request timeout is 30 seconds.
|
|
24
|
+
|
|
25
|
+
You can override the URL and timeouts or inject a Faraday adapter for tests.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
client = SendcloudAPI::Client.new(
|
|
29
|
+
public_key: "test-public-key",
|
|
30
|
+
secret_key: "test-secret-key",
|
|
31
|
+
base_url: "https://example.test/api/v3/",
|
|
32
|
+
open_timeout: 2,
|
|
33
|
+
timeout: 10,
|
|
34
|
+
adapter: [:test, stubs]
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Resources
|
|
39
|
+
|
|
40
|
+
Each resource method sends one request and returns a `SendcloudAPI::Response` with Sendcloud's body unchanged.
|
|
41
|
+
Payloads are Hashes with Sendcloud's JSON field names as String keys. List filters are keyword arguments with Sendcloud's query parameter names, as Symbol or String keys.
|
|
42
|
+
An unknown filter raises `ArgumentError` before any request is sent, because Sendcloud ignores unknown filters and returns every record.
|
|
43
|
+
Ids and tracking numbers are required and are escaped into one path segment. A value of `.` or `..` raises `ArgumentError` before sending, because it would change the path.
|
|
44
|
+
|
|
45
|
+
### Returns
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
created = client.returns.create(payload: {
|
|
49
|
+
"from_address" => from_address,
|
|
50
|
+
"to_address" => to_address,
|
|
51
|
+
"ship_with" => {"type" => "shipping_option_code", "shipping_option_code" => "sendcloud:letterreturn"},
|
|
52
|
+
"weight" => {"value" => 0.5, "unit" => "kg"},
|
|
53
|
+
"external_reference" => "postco-123"
|
|
54
|
+
})
|
|
55
|
+
return_id = created.body["return_id"]
|
|
56
|
+
parcel_id = created.body["parcel_id"]
|
|
57
|
+
|
|
58
|
+
client.returns.validate(payload: return_payload)
|
|
59
|
+
client.returns.retrieve(id: return_id)
|
|
60
|
+
client.returns.list(page_size: 40, from_date: "2026-10-01 00:00:00", to_date: "2026-10-31 00:00:00")
|
|
61
|
+
client.returns.cancel(id: return_id)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Keep the `parcel_id` from the create response. `retrieve` returns the return without a `data` wrapper and without `parcel_id` or `external_reference`.
|
|
65
|
+
The return's `status` stays `open`. The parcel's progress is the last `parent_status` in `status_history`, such as `ready-to-send` or `cancelled`.
|
|
66
|
+
A second return with a used `external_reference` raises `ConflictError`, which is not ambiguous and carries no return id.
|
|
67
|
+
`cancel` answers HTTP 202. Read the return to see the parcel reach `cancelled`.
|
|
68
|
+
`validate` only checks a return, so its failures are never ambiguous.
|
|
69
|
+
|
|
70
|
+
### Shipping options
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
client.shipping_options.search(payload: {
|
|
74
|
+
"from_country_code" => "GB",
|
|
75
|
+
"to_country_code" => "GB",
|
|
76
|
+
"functionalities" => {"returns" => true},
|
|
77
|
+
"parcels" => [{"weight" => {"value" => 1, "unit" => "kg"}}],
|
|
78
|
+
"calculate_quotes" => true
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The search only reads, so its failures are never ambiguous.
|
|
83
|
+
|
|
84
|
+
### Parcel documents
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
label = client.parcel_documents.retrieve(parcel_id: parcel_id, type: "label", format: :png, dpi: 300)
|
|
88
|
+
label.body # PNG bytes
|
|
89
|
+
label.content_type # "image/png"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`format` is `:pdf`, `:png`, or `:zpl` and sets the Accept header. The optional filters are `dpi` and `paper_size`.
|
|
93
|
+
Label documents can take a few seconds to appear after a return is created. Until then Sendcloud answers HTTP 404, which raises `NotFoundError`.
|
|
94
|
+
A carrier-native ZPL label is always returned as ZPL. A `:pdf` or `:png` request for one raises `ParseError` with `ambiguous? == false` and `error.response.content_type == "application/zpl"`.
|
|
95
|
+
Request it again with `format: :zpl`.
|
|
96
|
+
|
|
97
|
+
### Parcel tracking
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
client.parcel_tracking.retrieve(tracking_number: tracking_number)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Sendcloud answers HTTP 404 for a parcel its tracking service has not registered yet.
|
|
104
|
+
|
|
105
|
+
### Contracts
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
client.contracts.schemas(carrier_code: "dpd_gb")
|
|
109
|
+
client.contracts.list(carrier_code: "dpd_gb", is_active: true, page_size: 100)
|
|
110
|
+
contract = client.contracts.create(payload: {"carrier_code" => "dpd_gb", "is_active" => false, "contract_data" => contract_data})
|
|
111
|
+
client.contracts.retrieve(id: contract_id)
|
|
112
|
+
client.contracts.update(id: contract_id, payload: {"is_active" => true})
|
|
113
|
+
client.contracts.delete(id: contract_id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A new contract starts in the `validating` state. Read it again to see `active` or `validation_failed`.
|
|
117
|
+
|
|
118
|
+
### Integrations, account metadata, and invoice items
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
client.integrations.retrieve(id: integration_id)
|
|
122
|
+
client.integrations.update(id: integration_id, payload: {"webhook_active" => true, "webhook_url" => url})
|
|
123
|
+
client.user.auth_metadata
|
|
124
|
+
client.invoice_items.list(parcel_id: parcel_id, type: "shipment", created_after: "2026-10-01T00:00:00Z")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
An integration update answers HTTP 204 with no body.
|
|
128
|
+
|
|
129
|
+
### Pagination
|
|
130
|
+
|
|
131
|
+
List methods return one page. `SendcloudAPI::Pagination.next_params(page, params)` returns the params for the next page, or `nil` on the last page.
|
|
132
|
+
It keeps every filter in `params` and sets `cursor`. A Sendcloud cursor holds only a position, so a request with the cursor alone returns records the filters would exclude.
|
|
133
|
+
It reads the `Link` header that contracts and invoice items use, and the body `next` URL that returns use. A next URL without exactly one cursor, or one that does not parse, raises `ParseError`.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
contracts = []
|
|
137
|
+
params = {carrier_code: "dpd_gb", page_size: 100}
|
|
138
|
+
while params
|
|
139
|
+
page = client.contracts.list(**params)
|
|
140
|
+
contracts.concat(page.body["data"])
|
|
141
|
+
params = SendcloudAPI::Pagination.next_params(page, params)
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Webhook signatures
|
|
146
|
+
|
|
147
|
+
Sendcloud signs each webhook with the hex HMAC-SHA256 of the raw request body, keyed with the integration's secret key, in the `Sendcloud-Signature` header.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
valid = SendcloudAPI::WebhookSignature.verify(
|
|
151
|
+
request.raw_post,
|
|
152
|
+
request.headers[SendcloudAPI::WebhookSignature::HEADER],
|
|
153
|
+
secret_key
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Pass the unparsed body, because a parsed and encoded body has different bytes. The comparison runs in constant time.
|
|
158
|
+
A missing or wrong signature returns `false`, as does any value that is not 64 ASCII hex characters, whatever its encoding. A body that is not a String or an empty secret raises `ArgumentError`.
|
|
159
|
+
|
|
160
|
+
## Requests and responses
|
|
161
|
+
|
|
162
|
+
`client.connection` exposes these methods:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
get(path, params: {}, accept: "application/json")
|
|
166
|
+
post(path, payload:, read_only: false)
|
|
167
|
+
put(path, payload:)
|
|
168
|
+
patch(path, payload:)
|
|
169
|
+
delete(path, params: {})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Use endpoint paths relative to the configured base URL. Payloads are encoded as JSON without changing field names or amounts.
|
|
173
|
+
HTTP redirects are returned as errors, without another request.
|
|
174
|
+
A path or base URL that is not a valid URL raises `ArgumentError` before any request is sent. The message omits the value.
|
|
175
|
+
A path that starts with `/` or a scheme such as `https:`, or contains a `.` or `..` segment, also raises `ArgumentError` before sending. A leading `/` would replace the base URL's `/api/v3/`, and an absolute URL would send the Authorization header to another host.
|
|
176
|
+
|
|
177
|
+
Each successful call returns a `SendcloudAPI::Response` with `status`, normalized lowercase `headers`, `body`, and `content_type`.
|
|
178
|
+
`success?` identifies HTTP 200 through 299. `no_content?` identifies HTTP 204 or a nil body.
|
|
179
|
+
|
|
180
|
+
Every request sends the `accept` value as its Accept header. By default the connection expects JSON.
|
|
181
|
+
A JSON response keeps the provider's String keys. Content types ending in `+json` are JSON, and a missing content type is read as JSON.
|
|
182
|
+
To fetch a document, pass its media type. Sendcloud's parcel documents endpoint returns `application/pdf`, `image/png`, or `application/zpl` depending on Accept.
|
|
183
|
+
The body is then the unchanged response bytes in binary encoding.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
label = client.connection.get("parcels/#{parcel_id}/documents/label", accept: "image/png")
|
|
187
|
+
label.body # PNG bytes
|
|
188
|
+
label.content_type # "image/png"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A successful response in a different format than the caller expected raises `ParseError`.
|
|
192
|
+
That includes HTML answering a JSON request and ZPL answering a PNG request. Sendcloud returns native ZPL labels as ZPL whatever the Accept header says.
|
|
193
|
+
An empty successful response to a document request also raises `ParseError`.
|
|
194
|
+
|
|
195
|
+
Some Sendcloud POST endpoints only read, such as shipping option searches and return validation.
|
|
196
|
+
Pass `read_only: true` for those so their failures are never reported as ambiguous writes.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
client.connection.post("shipping-options", payload: query, read_only: true)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Errors and recovery
|
|
203
|
+
|
|
204
|
+
All errors below inherit from `SendcloudAPI::Error`.
|
|
205
|
+
|
|
206
|
+
| HTTP status | Error class |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| 400 | `BadRequestError` |
|
|
209
|
+
| 401 | `AuthenticationError` |
|
|
210
|
+
| 403 | `ForbiddenError` |
|
|
211
|
+
| 404 | `NotFoundError` |
|
|
212
|
+
| 405 | `MethodNotAllowedError` |
|
|
213
|
+
| 409 | `ConflictError` |
|
|
214
|
+
| 422 | `UnprocessableEntityError` |
|
|
215
|
+
| 429 | `RateLimitError` |
|
|
216
|
+
| 500 through 599 | `ServerError` |
|
|
217
|
+
| Other unsuccessful statuses | `Error` |
|
|
218
|
+
|
|
219
|
+
`TransportError` wraps Faraday and decompression failures. `ParseError` reports unreadable JSON or a response in an unexpected format.
|
|
220
|
+
A non-204 write response with empty or null JSON also raises `ParseError`.
|
|
221
|
+
HTTP errors take precedence over parsing errors, so an unreadable HTTP 503 response raises `ServerError`.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
begin
|
|
225
|
+
client.connection.get("user/auth/metadata")
|
|
226
|
+
rescue SendcloudAPI::RateLimitError => error
|
|
227
|
+
retry_after = error.retry_after
|
|
228
|
+
rescue SendcloudAPI::Error => error
|
|
229
|
+
status = error.status
|
|
230
|
+
details = error.body
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
HTTP errors expose `response`, `status`, `headers`, `body`, and `retry_after`.
|
|
235
|
+
Their messages join each error's `code`, `field`, `message`, `title`, `detail`, and `source` values.
|
|
236
|
+
That covers Sendcloud's two error list shapes, `{code, detail, source, status}` and `{code, field, message}`, and a string `errors` value from return lookups.
|
|
237
|
+
A `code` equal to the HTTP status is left out, so a returns 409 reads "HTTP 409: returns External reference has already been used in another return."
|
|
238
|
+
The joined details are capped at 1,000 characters. The body keeps every error.
|
|
239
|
+
|
|
240
|
+
`error.ambiguous?` is true for a POST, PUT, PATCH, or DELETE that fails in transport, receives HTTP 5xx, or returns an unreadable or unexpected response.
|
|
241
|
+
A write may already have applied. Read the provider's current state and reconcile the result before deciding whether to retry.
|
|
242
|
+
Reads, `read_only: true` POSTs, and HTTP 4xx rejections have `ambiguous? == false`. This flag describes mutation uncertainty, not retryability.
|
|
243
|
+
`request_method` is a Symbol. `request_path` omits query parameters.
|
|
244
|
+
Invalid JSON input fails before dispatch and raises `JSON::GeneratorError`.
|
|
245
|
+
|
|
246
|
+
The connection removes credentials before a response or error leaves it.
|
|
247
|
+
It knows the configured keys and their Basic authorization value, and it learns more from submitted and returned fields whose names look like credentials.
|
|
248
|
+
In a successful response, a known value of 8 or more bytes is removed wherever it appears. A shorter value is removed only where it is the whole string, so it cannot alter identifiers such as tracking numbers.
|
|
249
|
+
An error's message, body, headers, and request path lose every known value wherever it appears, whatever its length.
|
|
250
|
+
Fields named like credentials are replaced with `[FILTERED]`. Field names are never changed.
|
|
251
|
+
Successful JSON gets only those two rules, so ordinary text such as "Basic Letter" or a signed URL stays intact.
|
|
252
|
+
Error messages and non-JSON error bodies also lose `Basic` and `Bearer` values, `name=value` or `name: value` credentials, and URL user information.
|
|
253
|
+
Accepted documents are returned byte for byte.
|
|
254
|
+
Redaction does not change caller-owned inputs or outbound payloads.
|
|
255
|
+
Client, connection, resource, redactor, and response inspection omit authentication state and payloads.
|
|
256
|
+
Transport errors omit the upstream message and cause because those can retain the authenticated request.
|
|
257
|
+
Ordinary business data remains in responses and can appear in provider error messages.
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```console
|
|
262
|
+
mise exec -- bundle install
|
|
263
|
+
mise exec -- bundle exec rake
|
|
264
|
+
mise exec -- bundle exec rake build
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The default Rake task runs RSpec and Standard. CI tests Ruby 3.3 and 3.4.
|
|
268
|
+
Specs use Faraday's test adapter and a loopback HTTP server. They do not call Sendcloud.
|
|
269
|
+
The gemspec packages only the library, README, changelog, and license. The lockfile is not committed.
|
|
270
|
+
|
|
271
|
+
Add endpoint classes under `SendcloudAPI::Resources` as subclasses of `Base`.
|
|
272
|
+
They receive a connection in their constructor. Use `get(path, params:, allowed:, accept:)` for reads. It rejects filters missing from `allowed` and omits nil filters.
|
|
273
|
+
Use `connection.post`, `connection.put`, `connection.patch`, or `connection.delete` for writes, and `read_only: true` for a POST that only reads.
|
|
274
|
+
`validate_payload!` checks String keys and required fields. `segment` requires a path value and escapes it.
|
|
275
|
+
Add explicit requires in `lib/sendcloud_api.rb` and memoized resource accessors in `Client`.
|
|
276
|
+
|
|
277
|
+
## Releases
|
|
278
|
+
|
|
279
|
+
Publishing a GitHub release triggers `release.yml` only in `PostCo/sendcloud_api`.
|
|
280
|
+
Its build job requires the source commit to be merged into `main`, checks that the tag matches `v<VERSION>`, refuses version `0.0.0`, runs tests and lint, builds the gem, and uploads it as an artifact.
|
|
281
|
+
A second job in the GitHub `release` environment downloads that gem and pushes it through RubyGems OIDC.
|
|
282
|
+
Only that job can request an OIDC token, and it runs no repository code or dependencies.
|
|
283
|
+
|
|
284
|
+
Before the first release, register the RubyGems trusted publisher for owner `PostCo`, repository `sendcloud_api`, workflow `release.yml`, and environment `release`.
|
|
285
|
+
See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/).
|
|
286
|
+
The local Bundler release task runs tests and lint before its tag and publication tasks, but does not enforce main ancestry.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
class Client
|
|
5
|
+
DEFAULT_BASE_URL = "https://panel.sendcloud.sc/api/v3/"
|
|
6
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
7
|
+
DEFAULT_TIMEOUT = 30
|
|
8
|
+
|
|
9
|
+
attr_reader :connection
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
public_key:,
|
|
13
|
+
secret_key:,
|
|
14
|
+
base_url: DEFAULT_BASE_URL,
|
|
15
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
16
|
+
timeout: DEFAULT_TIMEOUT,
|
|
17
|
+
adapter: Faraday.default_adapter
|
|
18
|
+
)
|
|
19
|
+
validate_credential!(:public_key, public_key)
|
|
20
|
+
validate_credential!(:secret_key, secret_key)
|
|
21
|
+
|
|
22
|
+
@connection = Connection.new(
|
|
23
|
+
public_key: public_key,
|
|
24
|
+
secret_key: secret_key,
|
|
25
|
+
base_url: base_url,
|
|
26
|
+
open_timeout: open_timeout,
|
|
27
|
+
timeout: timeout,
|
|
28
|
+
adapter: adapter
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def returns
|
|
33
|
+
@returns ||= Resources::Returns.new(connection)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def shipping_options
|
|
37
|
+
@shipping_options ||= Resources::ShippingOptions.new(connection)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parcel_documents
|
|
41
|
+
@parcel_documents ||= Resources::ParcelDocuments.new(connection)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parcel_tracking
|
|
45
|
+
@parcel_tracking ||= Resources::ParcelTracking.new(connection)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def contracts
|
|
49
|
+
@contracts ||= Resources::Contracts.new(connection)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def integrations
|
|
53
|
+
@integrations ||= Resources::Integrations.new(connection)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def user
|
|
57
|
+
@user ||= Resources::User.new(connection)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def invoice_items
|
|
61
|
+
@invoice_items ||= Resources::InvoiceItems.new(connection)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class}>"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def validate_credential!(name, value)
|
|
71
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "#{name} must be a non-empty String"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module SendcloudAPI
|
|
8
|
+
class Connection
|
|
9
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
10
|
+
ERROR_DETAILS_LIMIT = 1_000
|
|
11
|
+
NOT_RELATIVE_PATH = %r{\A(?:/|[a-z][a-z0-9+.-]*:)}i
|
|
12
|
+
ERROR_CLASSES = {
|
|
13
|
+
400 => BadRequestError,
|
|
14
|
+
401 => AuthenticationError,
|
|
15
|
+
403 => ForbiddenError,
|
|
16
|
+
404 => NotFoundError,
|
|
17
|
+
405 => MethodNotAllowedError,
|
|
18
|
+
409 => ConflictError,
|
|
19
|
+
422 => UnprocessableEntityError,
|
|
20
|
+
429 => RateLimitError
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(public_key:, secret_key:, base_url:, open_timeout:, timeout:, adapter:)
|
|
24
|
+
authorization = Faraday::Utils.basic_header_from(public_key, secret_key)
|
|
25
|
+
@redactor = Redactor.new([public_key, secret_key, authorization, authorization.delete_prefix("Basic ")])
|
|
26
|
+
@http = Faraday.new(url: normalize_base_url(base_url)) do |connection|
|
|
27
|
+
connection.headers["Content-Type"] = JSON_MEDIA_TYPE
|
|
28
|
+
connection.headers["User-Agent"] = "sendcloud_api/#{VERSION}"
|
|
29
|
+
connection.headers["Authorization"] = authorization
|
|
30
|
+
connection.options.open_timeout = open_timeout
|
|
31
|
+
connection.options.timeout = timeout
|
|
32
|
+
connection.adapter(*Array(adapter))
|
|
33
|
+
end
|
|
34
|
+
rescue URI::InvalidURIError
|
|
35
|
+
raise ArgumentError, "base_url is not a valid URL", cause: nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get(path, params: {}, accept: JSON_MEDIA_TYPE)
|
|
39
|
+
request(:get, path, params: params, accept: accept)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def post(path, payload:, read_only: false)
|
|
43
|
+
request(:post, path, payload: payload, write: !read_only)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def put(path, payload:)
|
|
47
|
+
request(:put, path, payload: payload)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def patch(path, payload:)
|
|
51
|
+
request(:patch, path, payload: payload)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(path, params: {})
|
|
55
|
+
request(:delete, path, params: params)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def inspect
|
|
59
|
+
"#<#{self.class}>"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_base_url(base_url)
|
|
65
|
+
base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request(method, path, params: {}, payload: nil, accept: JSON_MEDIA_TYPE, write: method != :get)
|
|
69
|
+
raise ArgumentError, "path must be relative to the base URL, without a scheme or leading slash" if path.match?(NOT_RELATIVE_PATH)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, "path must not contain . or .. segments" if path.split(/[?#]/, 2).first.split("/").any? { |segment| segment == "." || segment == ".." }
|
|
72
|
+
|
|
73
|
+
body = serialize(payload) unless payload.nil?
|
|
74
|
+
redactor = @redactor.with_sensitive_values([payload, params])
|
|
75
|
+
context = {request_method: method, request_path: redactor.filter_text(path.split("?").first)}
|
|
76
|
+
|
|
77
|
+
raw_response = dispatch(method, path, body, params, accept, context, write: write)
|
|
78
|
+
status = raw_response.status
|
|
79
|
+
success = (200..299).cover?(status)
|
|
80
|
+
headers = raw_response.headers.to_h.transform_keys { |key| key.to_s.downcase }
|
|
81
|
+
kind, body, problem = decode(success, raw_response.body, media_type(headers["content-type"]), accept.downcase)
|
|
82
|
+
problem ||= "Sendcloud returned empty JSON" if write && body.nil? && status != 204
|
|
83
|
+
redactor = redactor.with_sensitive_values([headers, body])
|
|
84
|
+
redactor = redactor.anywhere if problem || !success
|
|
85
|
+
body = redactor.filter(body) if kind == :json
|
|
86
|
+
body = redactor.filter_text(body) if kind == :text
|
|
87
|
+
response = Response.new(status: status, headers: redactor.filter(headers), body: body)
|
|
88
|
+
|
|
89
|
+
raise_for_status!(response, redactor, context, write: write)
|
|
90
|
+
raise ParseError.new(redactor.filter_text(problem), response: response, ambiguous: write, **context), cause: nil if problem
|
|
91
|
+
|
|
92
|
+
response
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def serialize(payload)
|
|
96
|
+
JSON.generate(payload)
|
|
97
|
+
rescue JSON::GeneratorError => error
|
|
98
|
+
raise error.class, @redactor.with_sensitive_values(payload).filter_text(error.message), cause: nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def dispatch(method, path, body, params, accept, context, write:)
|
|
102
|
+
@http.run_request(method, path, body, {"Accept" => accept}) do |request|
|
|
103
|
+
request.params.update(params)
|
|
104
|
+
end
|
|
105
|
+
rescue URI::InvalidURIError
|
|
106
|
+
raise ArgumentError, "path is not a valid URL path", cause: nil
|
|
107
|
+
rescue Faraday::Error, Zlib::Error => error
|
|
108
|
+
raise TransportError.new("Sendcloud transport failed (#{error.class})", ambiguous: write, **context), cause: nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def media_type(content_type)
|
|
112
|
+
content_type.to_s.split(";").first.to_s.strip.downcase
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def json_type?(media_type)
|
|
116
|
+
media_type == JSON_MEDIA_TYPE || media_type.end_with?("+json")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def decode(success, body, media_type, accept)
|
|
120
|
+
return decode_document(body, media_type, accept) if success && !json_type?(accept)
|
|
121
|
+
return [:json, nil, nil] if body.nil? || body.empty?
|
|
122
|
+
return parse_json(body) if media_type.empty? || json_type?(media_type)
|
|
123
|
+
|
|
124
|
+
[:text, body.b, ("Sendcloud returned #{media_type_name(media_type)} instead of JSON" if success)]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decode_document(body, media_type, accept)
|
|
128
|
+
return [:document, nil, "Sendcloud returned an empty body instead of #{accept}"] if body.nil? || body.empty?
|
|
129
|
+
return [:document, body.b, nil] if media_type == accept
|
|
130
|
+
|
|
131
|
+
[:text, body.b, "Sendcloud returned #{media_type_name(media_type)} instead of #{accept}"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def media_type_name(media_type)
|
|
135
|
+
media_type.empty? ? "no content type" : media_type
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_json(body)
|
|
139
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
140
|
+
return [:text, text.scrub, "Sendcloud returned invalid JSON"] unless text.valid_encoding?
|
|
141
|
+
|
|
142
|
+
parsed = JSON.parse(text)
|
|
143
|
+
valid_utf8?(parsed) ? [:json, parsed, nil] : [:text, Redactor::FILTERED, "Sendcloud returned invalid JSON"]
|
|
144
|
+
rescue JSON::ParserError
|
|
145
|
+
[:text, text, "Sendcloud returned invalid JSON"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def valid_utf8?(value)
|
|
149
|
+
case value
|
|
150
|
+
when Hash
|
|
151
|
+
value.all? { |key, entry| valid_utf8?(key) && valid_utf8?(entry) }
|
|
152
|
+
when Array
|
|
153
|
+
value.all? { |entry| valid_utf8?(entry) }
|
|
154
|
+
when String
|
|
155
|
+
value.valid_encoding?
|
|
156
|
+
else
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def raise_for_status!(response, redactor, context, write:)
|
|
162
|
+
return if response.success?
|
|
163
|
+
|
|
164
|
+
error_class = ERROR_CLASSES.fetch(response.status) { (500..599).cover?(response.status) ? ServerError : Error }
|
|
165
|
+
message = "Sendcloud request failed with HTTP #{response.status}"
|
|
166
|
+
details = error_details(response.body, response.status)
|
|
167
|
+
message += ": #{truncate(redactor.filter_text(details))}" if details
|
|
168
|
+
raise error_class.new(message, response: response,
|
|
169
|
+
ambiguous: write && (500..599).cover?(response.status), **context), cause: nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def error_details(body, status)
|
|
173
|
+
return unless body.is_a?(Hash)
|
|
174
|
+
return body["errors"] if body["errors"].is_a?(String)
|
|
175
|
+
return unless body["errors"].is_a?(Array)
|
|
176
|
+
|
|
177
|
+
details = body["errors"].filter_map do |error|
|
|
178
|
+
next unless error.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
error = error.except("code") if error["code"].to_s == status.to_s
|
|
181
|
+
error.values_at("code", "field", "message", "title", "detail", "source")
|
|
182
|
+
.compact.map { |value| value.is_a?(String) ? value : JSON.generate(value) }.join(" ")
|
|
183
|
+
end.reject(&:empty?)
|
|
184
|
+
details.join("; ") unless details.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def truncate(details)
|
|
188
|
+
(details.length > ERROR_DETAILS_LIMIT) ? "#{details[0, ERROR_DETAILS_LIMIT]}..." : details
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :response, :request_method, :request_path
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, response: nil, ambiguous: false, request_method: nil, request_path: nil)
|
|
8
|
+
@response = response
|
|
9
|
+
@ambiguous = ambiguous
|
|
10
|
+
@request_method = request_method
|
|
11
|
+
@request_path = request_path
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ambiguous?
|
|
16
|
+
@ambiguous
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def status
|
|
20
|
+
response&.status
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def headers
|
|
24
|
+
response&.headers
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def body
|
|
28
|
+
response&.body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def retry_after
|
|
32
|
+
headers&.fetch("retry-after", nil)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class BadRequestError < Error; end
|
|
37
|
+
class AuthenticationError < Error; end
|
|
38
|
+
class ForbiddenError < Error; end
|
|
39
|
+
class NotFoundError < Error; end
|
|
40
|
+
class MethodNotAllowedError < Error; end
|
|
41
|
+
class ConflictError < Error; end
|
|
42
|
+
class UnprocessableEntityError < Error; end
|
|
43
|
+
class RateLimitError < Error; end
|
|
44
|
+
class ServerError < Error; end
|
|
45
|
+
class TransportError < Error; end
|
|
46
|
+
class ParseError < Error; end
|
|
47
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SendcloudAPI
|
|
6
|
+
module Pagination
|
|
7
|
+
LINK_VALUE = /<([^>]*)>([^<]*)/
|
|
8
|
+
REL = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i
|
|
9
|
+
|
|
10
|
+
def self.next_params(response, params)
|
|
11
|
+
url = next_link(response.headers["link"])
|
|
12
|
+
url ||= response.body["next"] if response.body.is_a?(Hash)
|
|
13
|
+
return unless url
|
|
14
|
+
|
|
15
|
+
params.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }.merge(cursor: cursor(url, response))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.next_link(header)
|
|
19
|
+
header.to_s.scan(LINK_VALUE).find do |_url, attributes|
|
|
20
|
+
rel = attributes.match(REL)
|
|
21
|
+
rel && (rel[1] || rel[2]).split.any? { |type| type.casecmp?("next") }
|
|
22
|
+
end&.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.cursor(url, response)
|
|
26
|
+
cursors = URI.decode_www_form(URI(url).query.to_s).filter_map { |name, value| value if name == "cursor" }
|
|
27
|
+
return cursors.first if cursors.size == 1 && !cursors.first.empty?
|
|
28
|
+
|
|
29
|
+
raise ParseError.new("Sendcloud returned a next page URL without one cursor", response: response)
|
|
30
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
31
|
+
raise ParseError.new("Sendcloud returned an invalid next page URL", response: response), cause: nil
|
|
32
|
+
end
|
|
33
|
+
private_class_method :next_link, :cursor
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SendcloudAPI
|
|
6
|
+
class Redactor
|
|
7
|
+
FILTERED = "[FILTERED]"
|
|
8
|
+
CREDENTIAL_NAME = /authorization|authentication|password|passwd|secret|token|cookie|api[-_]?key|public[-_]?key/i
|
|
9
|
+
CREDENTIAL_ASSIGNMENT = /([\w-]*(?:#{CREDENTIAL_NAME.source})[\w-]*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&<>]+)/i
|
|
10
|
+
MINIMUM_EMBEDDED_BYTES = 8
|
|
11
|
+
|
|
12
|
+
def initialize(secrets = [], anywhere: false)
|
|
13
|
+
@secrets = secrets.grep(String).reject(&:empty?).uniq.freeze
|
|
14
|
+
@anywhere = anywhere
|
|
15
|
+
variants = @secrets.flat_map { |value| [value, URI.encode_www_form_component(value)] }.map(&:b).uniq.sort_by { |value| -value.bytesize }
|
|
16
|
+
short, long = variants.partition { |value| value.bytesize < MINIMUM_EMBEDDED_BYTES }
|
|
17
|
+
@all = union(variants)
|
|
18
|
+
@embedded = anywhere ? @all : union(long)
|
|
19
|
+
@whole = anywhere ? [].freeze : short.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_sensitive_values(value)
|
|
23
|
+
self.class.new(@secrets + sensitive_values(value), anywhere: @anywhere)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def anywhere
|
|
27
|
+
self.class.new(@secrets, anywhere: true)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def filter(value)
|
|
31
|
+
case value
|
|
32
|
+
when Hash
|
|
33
|
+
value.to_h { |key, entry| [key, credential_name?(key) ? FILTERED : filter(entry)] }
|
|
34
|
+
when Array
|
|
35
|
+
value.map { |entry| filter(entry) }
|
|
36
|
+
when String
|
|
37
|
+
filter_secrets(value)
|
|
38
|
+
else
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filter_text(value)
|
|
44
|
+
filtered = value.b.gsub(@all, FILTERED)
|
|
45
|
+
filtered = filtered.gsub(%r{(https?://)[^\s/@]+:[^\s/@]+@}i, "\\1#{FILTERED}@")
|
|
46
|
+
filtered = filtered.gsub(/\b(Bearer|Basic)\s+[^\s,"'<>]+/i, "\\1 #{FILTERED}")
|
|
47
|
+
filtered.gsub(CREDENTIAL_ASSIGNMENT, "\\1#{FILTERED}").force_encoding(value.encoding)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect
|
|
51
|
+
"#<#{self.class}>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def credential_name?(key)
|
|
57
|
+
key.to_s.gsub(/[^a-z0-9]/i, "").match?(CREDENTIAL_NAME)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def filter_secrets(value)
|
|
61
|
+
bytes = value.b
|
|
62
|
+
return FILTERED.dup.force_encoding(value.encoding) if @whole.include?(bytes)
|
|
63
|
+
|
|
64
|
+
bytes.gsub(@embedded, FILTERED).force_encoding(value.encoding)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def union(values)
|
|
68
|
+
return /(?!)/n if values.empty?
|
|
69
|
+
|
|
70
|
+
Regexp.new(values.map { |value| Regexp.escape(value) }.join("|").b, Regexp::NOENCODING)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sensitive_values(value)
|
|
74
|
+
case value
|
|
75
|
+
when Hash
|
|
76
|
+
value.flat_map { |key, entry| credential_name?(key) ? strings_in(entry) : sensitive_values(entry) }
|
|
77
|
+
when Array
|
|
78
|
+
value.flat_map { |entry| sensitive_values(entry) }
|
|
79
|
+
else
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def strings_in(value)
|
|
85
|
+
case value
|
|
86
|
+
when Hash then value.values.flat_map { |entry| strings_in(entry) }
|
|
87
|
+
when Array then value.flat_map { |entry| strings_in(entry) }
|
|
88
|
+
when String then [value, value.sub(/\A(?:Bearer|Basic)\s+/i, "")]
|
|
89
|
+
else []
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SendcloudAPI
|
|
6
|
+
module Resources
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :connection
|
|
9
|
+
|
|
10
|
+
def initialize(connection)
|
|
11
|
+
@connection = connection
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inspect
|
|
15
|
+
"#<#{self.class}>"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def get(path, params: {}, allowed: [], accept: Connection::JSON_MEDIA_TYPE)
|
|
21
|
+
params = params.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
22
|
+
unknown = params.keys - allowed
|
|
23
|
+
raise ArgumentError, "Unknown parameters: #{unknown.map(&:to_s).sort.join(", ")}" unless unknown.empty?
|
|
24
|
+
|
|
25
|
+
connection.get(path, params: params.compact, accept: accept)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def segment(name, value)
|
|
29
|
+
require_parameter!(name, value)
|
|
30
|
+
raise ArgumentError, "#{name} cannot be . or .." if %w[. ..].include?(value.to_s)
|
|
31
|
+
|
|
32
|
+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_payload!(payload, *identifiers)
|
|
36
|
+
unless payload.is_a?(Hash) && payload.keys.all? { |key| key.is_a?(String) }
|
|
37
|
+
raise ArgumentError, "payload must be a Hash with Sendcloud JSON field names as String keys"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
identifiers.each { |name| require_parameter!(name, payload[name]) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def require_parameter!(name, value)
|
|
44
|
+
missing = value.nil? || (value.is_a?(String) ? value.strip.empty? : value.respond_to?(:empty?) && value.empty?)
|
|
45
|
+
return unless missing
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "#{name} is required"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
module Resources
|
|
5
|
+
class Contracts < Base
|
|
6
|
+
LIST_PARAMETERS = %i[carrier_code is_active client_id name country_code cursor page_size].freeze
|
|
7
|
+
|
|
8
|
+
def list(**filters)
|
|
9
|
+
get("contracts", params: filters, allowed: LIST_PARAMETERS)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create(payload:)
|
|
13
|
+
validate_payload!(payload, "carrier_code")
|
|
14
|
+
connection.post("contracts", payload: payload)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def retrieve(id:)
|
|
18
|
+
get("contracts/#{segment(:id, id)}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def update(id:, payload:)
|
|
22
|
+
validate_payload!(payload)
|
|
23
|
+
connection.patch("contracts/#{segment(:id, id)}", payload: payload)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def delete(id:)
|
|
27
|
+
connection.delete("contracts/#{segment(:id, id)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def schemas(carrier_code: nil)
|
|
31
|
+
get("contracts/schemas", params: {carrier_code: carrier_code}, allowed: %i[carrier_code])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
module Resources
|
|
5
|
+
class Integrations < Base
|
|
6
|
+
def retrieve(id:)
|
|
7
|
+
get("integrations/#{segment(:id, id)}")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def update(id:, payload:)
|
|
11
|
+
validate_payload!(payload)
|
|
12
|
+
connection.patch("integrations/#{segment(:id, id)}", payload: payload)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
module Resources
|
|
5
|
+
class InvoiceItems < Base
|
|
6
|
+
LIST_PARAMETERS = %i[created_after created_before parcel_id invoice_id type cursor page_size].freeze
|
|
7
|
+
|
|
8
|
+
def list(**filters)
|
|
9
|
+
get("invoice-items", params: filters, allowed: LIST_PARAMETERS)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
module Resources
|
|
5
|
+
class ParcelDocuments < Base
|
|
6
|
+
FORMATS = {pdf: "application/pdf", png: "image/png", zpl: "application/zpl"}.freeze
|
|
7
|
+
PARAMETERS = %i[dpi paper_size].freeze
|
|
8
|
+
|
|
9
|
+
def retrieve(parcel_id:, type:, format:, **options)
|
|
10
|
+
accept = FORMATS.fetch(format) { raise ArgumentError, "format must be one of: #{FORMATS.keys.join(", ")}" }
|
|
11
|
+
path = "parcels/#{segment(:parcel_id, parcel_id)}/documents/#{segment(:type, type)}"
|
|
12
|
+
get(path, params: options, allowed: PARAMETERS, accept: accept)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
module Resources
|
|
5
|
+
class Returns < Base
|
|
6
|
+
LIST_PARAMETERS = %i[cursor page_size from_date to_date parent_parcel_status].freeze
|
|
7
|
+
|
|
8
|
+
def create(payload:)
|
|
9
|
+
validate_payload!(payload)
|
|
10
|
+
connection.post("returns", payload: payload)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate(payload:)
|
|
14
|
+
validate_payload!(payload)
|
|
15
|
+
connection.post("returns/validate", payload: payload, read_only: true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def retrieve(id:)
|
|
19
|
+
get("returns/#{segment(:id, id)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def list(**filters)
|
|
23
|
+
get("returns", params: filters, allowed: LIST_PARAMETERS)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cancel(id:)
|
|
27
|
+
connection.patch("returns/#{segment(:id, id)}/cancel", payload: {})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendcloudAPI
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :status, :headers, :body
|
|
6
|
+
|
|
7
|
+
def initialize(status:, headers:, body:)
|
|
8
|
+
@status = status
|
|
9
|
+
@headers = headers.freeze
|
|
10
|
+
@body = body
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def content_type
|
|
15
|
+
headers["content-type"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def success?
|
|
19
|
+
(200..299).cover?(status)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def no_content?
|
|
23
|
+
status == 204 || body.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<#{self.class} status=#{status}>"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SendcloudAPI
|
|
6
|
+
module WebhookSignature
|
|
7
|
+
HEADER = "Sendcloud-Signature"
|
|
8
|
+
HEX_SHA256 = /\A\h{64}\z/
|
|
9
|
+
|
|
10
|
+
def self.verify(raw_body, signature, secret)
|
|
11
|
+
raise ArgumentError, "raw_body must be the unparsed request body String" unless raw_body.is_a?(String)
|
|
12
|
+
raise ArgumentError, "secret must be a non-empty String" unless secret.is_a?(String) && !secret.empty?
|
|
13
|
+
hex = signature.b if signature.is_a?(String)
|
|
14
|
+
return false unless hex&.match?(HEX_SHA256)
|
|
15
|
+
|
|
16
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
|
|
17
|
+
OpenSSL.fixed_length_secure_compare(expected, hex.downcase)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sendcloud_api/version"
|
|
4
|
+
require_relative "sendcloud_api/errors"
|
|
5
|
+
require_relative "sendcloud_api/response"
|
|
6
|
+
require_relative "sendcloud_api/redactor"
|
|
7
|
+
require_relative "sendcloud_api/connection"
|
|
8
|
+
require_relative "sendcloud_api/pagination"
|
|
9
|
+
require_relative "sendcloud_api/webhook_signature"
|
|
10
|
+
require_relative "sendcloud_api/resources/base"
|
|
11
|
+
require_relative "sendcloud_api/resources/returns"
|
|
12
|
+
require_relative "sendcloud_api/resources/shipping_options"
|
|
13
|
+
require_relative "sendcloud_api/resources/parcel_documents"
|
|
14
|
+
require_relative "sendcloud_api/resources/parcel_tracking"
|
|
15
|
+
require_relative "sendcloud_api/resources/contracts"
|
|
16
|
+
require_relative "sendcloud_api/resources/integrations"
|
|
17
|
+
require_relative "sendcloud_api/resources/user"
|
|
18
|
+
require_relative "sendcloud_api/resources/invoice_items"
|
|
19
|
+
require_relative "sendcloud_api/client"
|
|
20
|
+
|
|
21
|
+
module SendcloudAPI
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sendcloud_api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- PostCo
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: faraday
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.7'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '2.7'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: uri
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.11'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0.11'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '2'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: rake
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
type: :development
|
|
61
|
+
prerelease: false
|
|
62
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '13.0'
|
|
67
|
+
- !ruby/object:Gem::Dependency
|
|
68
|
+
name: rspec
|
|
69
|
+
requirement: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.12'
|
|
74
|
+
- - "<"
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '4'
|
|
77
|
+
type: :development
|
|
78
|
+
prerelease: false
|
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '3.12'
|
|
84
|
+
- - "<"
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '4'
|
|
87
|
+
- !ruby/object:Gem::Dependency
|
|
88
|
+
name: standard
|
|
89
|
+
requirement: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '1.35'
|
|
94
|
+
- - "<"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2'
|
|
97
|
+
type: :development
|
|
98
|
+
prerelease: false
|
|
99
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '1.35'
|
|
104
|
+
- - "<"
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '2'
|
|
107
|
+
description: A small, explicit Ruby client for Sendcloud API v3 reads and writes,
|
|
108
|
+
without automatic retries.
|
|
109
|
+
email:
|
|
110
|
+
- engineering@postco.co
|
|
111
|
+
executables: []
|
|
112
|
+
extensions: []
|
|
113
|
+
extra_rdoc_files: []
|
|
114
|
+
files:
|
|
115
|
+
- CHANGELOG.md
|
|
116
|
+
- LICENSE.txt
|
|
117
|
+
- README.md
|
|
118
|
+
- lib/sendcloud_api.rb
|
|
119
|
+
- lib/sendcloud_api/client.rb
|
|
120
|
+
- lib/sendcloud_api/connection.rb
|
|
121
|
+
- lib/sendcloud_api/errors.rb
|
|
122
|
+
- lib/sendcloud_api/pagination.rb
|
|
123
|
+
- lib/sendcloud_api/redactor.rb
|
|
124
|
+
- lib/sendcloud_api/resources/base.rb
|
|
125
|
+
- lib/sendcloud_api/resources/contracts.rb
|
|
126
|
+
- lib/sendcloud_api/resources/integrations.rb
|
|
127
|
+
- lib/sendcloud_api/resources/invoice_items.rb
|
|
128
|
+
- lib/sendcloud_api/resources/parcel_documents.rb
|
|
129
|
+
- lib/sendcloud_api/resources/parcel_tracking.rb
|
|
130
|
+
- lib/sendcloud_api/resources/returns.rb
|
|
131
|
+
- lib/sendcloud_api/resources/shipping_options.rb
|
|
132
|
+
- lib/sendcloud_api/resources/user.rb
|
|
133
|
+
- lib/sendcloud_api/response.rb
|
|
134
|
+
- lib/sendcloud_api/version.rb
|
|
135
|
+
- lib/sendcloud_api/webhook_signature.rb
|
|
136
|
+
homepage: https://github.com/PostCo/sendcloud_api
|
|
137
|
+
licenses:
|
|
138
|
+
- MIT
|
|
139
|
+
metadata:
|
|
140
|
+
allowed_push_host: https://rubygems.org
|
|
141
|
+
homepage_uri: https://github.com/PostCo/sendcloud_api
|
|
142
|
+
source_code_uri: https://github.com/PostCo/sendcloud_api
|
|
143
|
+
changelog_uri: https://github.com/PostCo/sendcloud_api/blob/main/CHANGELOG.md
|
|
144
|
+
rubygems_mfa_required: 'true'
|
|
145
|
+
post_install_message:
|
|
146
|
+
rdoc_options: []
|
|
147
|
+
require_paths:
|
|
148
|
+
- lib
|
|
149
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
150
|
+
requirements:
|
|
151
|
+
- - ">="
|
|
152
|
+
- !ruby/object:Gem::Version
|
|
153
|
+
version: 3.3.0
|
|
154
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '0'
|
|
159
|
+
requirements: []
|
|
160
|
+
rubygems_version: 3.5.22
|
|
161
|
+
signing_key:
|
|
162
|
+
specification_version: 4
|
|
163
|
+
summary: A Ruby client for the Sendcloud API v3
|
|
164
|
+
test_files: []
|