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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendcloudAPI
4
+ module Resources
5
+ class ParcelTracking < Base
6
+ def retrieve(tracking_number:)
7
+ get("parcels/tracking/#{segment(:tracking_number, tracking_number)}")
8
+ end
9
+ end
10
+ end
11
+ 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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendcloudAPI
4
+ module Resources
5
+ class ShippingOptions < Base
6
+ def search(payload:)
7
+ validate_payload!(payload)
8
+ connection.post("shipping-options", payload: payload, read_only: true)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendcloudAPI
4
+ module Resources
5
+ class User < Base
6
+ def auth_metadata
7
+ get("user/auth/metadata")
8
+ end
9
+ end
10
+ end
11
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SendcloudAPI
4
+ VERSION = "0.1.0"
5
+ 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: []