cin7_core_api 0.2.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: 5c48f34c3de3d21ac9674e0bb133a3b9a16158ae0eb5087fcaafdf8e8543f794
4
+ data.tar.gz: e452799a7aa9ebf7b988d7146df5f01cc0f23c8be9c8213cda495dff6549653d
5
+ SHA512:
6
+ metadata.gz: 0acaa3e83cfd47241d741a4fee553505f558ef091ae971e1f1a8753de40e510b89b03ea17bfc1d2aba60932261d1eddbfbbb20f8deb3d962b566261844bfca99
7
+ data.tar.gz: fbd694cb6d780b6c718540e02ab0ee6f923f2c33b93ffc65243ff6293453c438dc15b20903e3f8027fa1a3aaa4770931fa9995481ae7b623ef9ff22c9b7d8093
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### 0.2.0 (release preparation)
6
+
7
+ - Add explicit sale update/undo, order, invoice, payment, sale manual-journal, standalone journal, tax-rule and webhook operations while preserving existing read methods.
8
+ - Send JSON payloads without rounding amounts or FX rates; distinguish parent sale IDs, document task IDs, payment IDs and webhook IDs.
9
+ - Expose request method/path and `ambiguous?` on errors for uncertain write outcomes after transport failures, server errors or invalid success responses. Never retry automatically.
10
+ - Sanitize credentials in response bodies and headers, omit sensitive transport causes and response details from error messages, and make object inspection safe for logging.
11
+ - Document document-replacement semantics, recovery lookup contracts, webhook constraints and release verification. Live financial behavior remains the caller's responsibility to verify.
12
+
13
+ ## [0.1.0] - 2026-08-12
14
+
15
+ - Add tenant-scoped authentication for the CIN7 Core API v2.
16
+ - Add read-only clients for account, customer, location, sale, credit note, and payment resources.
17
+ - Add transparent responses and typed HTTP, transport, and JSON parsing errors.
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,262 @@
1
+ # CIN7 Core API
2
+
3
+ `cin7_core_api` is a small Ruby client for the [CIN7 Core API v2](https://dearinventory.docs.apiary.io/).
4
+
5
+ Version `0.2.0` adds explicit payment, order, invoice, journal, and webhook operations while preserving the `0.1.x` read methods. It performs one request per call, without automatic retries or accounting policy. The caller owns authorization, concurrency, document matching, financial calculations, and recovery after uncertain writes.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "cin7_core_api", github: "PostCo/cin7_core_api", ref: "<reviewed-full-commit-sha>"
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```console
18
+ bundle install
19
+ ```
20
+
21
+ Ruby 3.3 or newer is required.
22
+
23
+ The `0.2.0` source is prepared for review; this README does not assert that it has been published. Pin a reviewed source commit until a release is available.
24
+
25
+ ## Authentication
26
+
27
+ Create a client using the Account ID and API Application Key from CIN7 Core's API setup page:
28
+
29
+ ```ruby
30
+ client = Cin7CoreAPI::Client.new(
31
+ account_id: ENV.fetch("CIN7_CORE_ACCOUNT_ID"),
32
+ application_key: ENV.fetch("CIN7_CORE_APPLICATION_KEY")
33
+ )
34
+ ```
35
+
36
+ Credentials belong to one CIN7 Core company and should not be shared or logged. Configuration is held by the client instance, so an application can safely construct separate clients for different retailers.
37
+
38
+ For an approved sandbox or mock server, the URL and timeouts can be overridden:
39
+
40
+ ```ruby
41
+ client = Cin7CoreAPI::Client.new(
42
+ account_id: "account-id",
43
+ application_key: "application-key",
44
+ base_url: "https://example.test/ExternalApi/v2/",
45
+ open_timeout: 2,
46
+ timeout: 10
47
+ )
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ The gem returns `Cin7CoreAPI::Response` objects. Response JSON retains CIN7's original key names:
53
+
54
+ ```ruby
55
+ response = client.sales.list(page: 1, limit: 100, external_id: "shopify-order-id")
56
+
57
+ response.status # => 200
58
+ response.success? # => true
59
+ response.headers # => {"content-type" => "application/json", ...}
60
+ response.body["SaleList"]
61
+ response.body["Total"]
62
+ ```
63
+
64
+ Available read operations:
65
+
66
+ | Ruby operation | CIN7 Core endpoint |
67
+ | --- | --- |
68
+ | `client.me.retrieve` | `GET /me` |
69
+ | `client.sales.list` | `GET /saleList` |
70
+ | `client.sales.retrieve(id:)` | `GET /sale?ID=...` |
71
+ | `client.credit_notes.list` | `GET /saleCreditNoteList` |
72
+ | `client.credit_notes.for_sale(sale_id:)` | `GET /sale/creditnote?SaleID=...` |
73
+ | `client.payments.for_sale(sale_id:)` | `GET /sale/payment?SaleID=...` |
74
+ | `client.locations.list` | `GET /ref/location` |
75
+ | `client.accounts.list` | `GET /ref/account` |
76
+ | `client.bank_accounts.list` | `GET /ref/account/bank` |
77
+ | `client.customers.list` | `GET /customer` |
78
+ | `client.customer_credits.list` | `GET /ref/customer/credits` |
79
+
80
+ Ruby keyword arguments use `snake_case` and are explicitly translated to CIN7's parameter names:
81
+
82
+ ```ruby
83
+ client.sales.list(
84
+ page: 2,
85
+ limit: 100,
86
+ updated_since: "2026-08-01T00:00:00.000",
87
+ order_status: "AUTHORISED"
88
+ )
89
+
90
+ client.sales.retrieve(
91
+ id: "7d636591-9d84-4ff1-a4fb-b9ab6f8b7cd7",
92
+ include_transactions: true
93
+ )
94
+
95
+ client.credit_notes.for_sale(
96
+ sale_id: "7d636591-9d84-4ff1-a4fb-b9ab6f8b7cd7",
97
+ include_payment_info: true
98
+ )
99
+
100
+ client.customer_credits.list(
101
+ customer_id: "c786e00a-d745-4c4f-9f95-cf06149b4925",
102
+ show_used_credits: false
103
+ )
104
+ ```
105
+
106
+ Unknown keyword arguments raise `ArgumentError` rather than silently sending a misspelled CIN7 parameter.
107
+
108
+ `ExternalID` is an optional Cin7 field and may be null on native connector imports. It is not a guaranteed Shopify order identifier. Resolve and persist the parent sale ID and the specific invoice/credit-note task IDs using verified business references. Do not substitute a parent `SaleID` for an advanced sale's document `TaskID`.
109
+
110
+ ## Pilot operations
111
+
112
+ Every operation returns the same `Cin7CoreAPI::Response` wrapper as the read methods. Payloads must be Hashes with Cin7 JSON field names as **String keys**. They are encoded as JSON, without case conversion, accounting calculations, FX rounding, or implicit document fields. Identifier presence is checked locally; Cin7 validates the remaining payload and state transitions.
113
+
114
+ | Ruby method | HTTP operation | Required payload identifiers / read filters |
115
+ | --- | --- | --- |
116
+ | `client.sales.update(payload:)` | `PUT /sale` | `ID` |
117
+ | `client.sales.undo(id:)` | `DELETE /sale?ID=...&Void=false` | Parent sale ID |
118
+ | `client.payments.create(payload:)` | `POST /sale/payment` | `TaskID` of the invoice or credit note |
119
+ | `client.payments.update(payload:)` | `PUT /sale/payment` | Payment `ID` |
120
+ | `client.payments.delete(id:)` | `DELETE /sale/payment?ID=...` | Payment ID |
121
+ | `client.orders.for_sale(sale_id:, **options)` | `GET /sale/order` | `sale_id`, `combine_additional_charges`, `include_product_info` |
122
+ | `client.orders.create(payload:)` | `POST /sale/order` | `SaleID` |
123
+ | `client.invoices.for_sale(sale_id:, **options)` | `GET /sale/invoice` | `sale_id`, `combine_additional_charges`, `include_product_info` |
124
+ | `client.invoices.create(payload:)` | `POST /sale/invoice` | `SaleID`, `TaskID` |
125
+ | `client.invoices.update(payload:)` | `PUT /sale/invoice` | `SaleID`, `TaskID` |
126
+ | `client.invoices.undo(task_id:)` | `DELETE /sale/invoice?TaskID=...&Void=false` | Invoice task ID |
127
+ | `client.manual_journals.for_sale(sale_id:)` | `GET /sale/manualJournal` | Parent sale ID |
128
+ | `client.manual_journals.create(payload:)` | `POST /sale/manualJournal` | `SaleID` |
129
+ | `client.journals.list(**filters)` | `GET /journal` | `page`, `limit`, `task_id`, `status`, `search` |
130
+ | `client.journals.retrieve(task_id:)` | `GET /journal?TaskID=...` | Journal task ID; retains the `Journals` response envelope |
131
+ | `client.journals.create(payload:)` | `POST /journal` | Server allocates the journal task ID |
132
+ | `client.journals.update(payload:)` | `PUT /journal` | `TaskID` |
133
+ | `client.tax_rules.list(**filters)` | `GET /ref/tax` | `page`, `limit`, `id`, `name`, `is_active`, `is_tax_for_sale`, `is_tax_for_purchase`, `account` |
134
+ | `client.webhooks.list` | `GET /webhooks` | No filters; returns the `Webhooks` envelope |
135
+ | `client.webhooks.create(payload:)` | `POST /webhooks` | Server allocates the subscription ID |
136
+ | `client.webhooks.update(payload:)` | `PUT /webhooks` | Subscription `ID` |
137
+ | `client.webhooks.delete(id:)` | `DELETE /webhooks?ID=...` | Subscription ID |
138
+
139
+ ### Payments and document corrections
140
+
141
+ ```ruby
142
+ response = client.payments.create(payload: {
143
+ "TaskID" => matched_credit_note_task_id,
144
+ "Type" => "REFUND",
145
+ "Reference" => persisted_operation_reference,
146
+ "Amount" => 60.00,
147
+ "DatePaid" => "2026-09-29T00:00:00",
148
+ "Account" => merchant_liability_account_code,
149
+ "CurrencyRate" => 1.42541
150
+ })
151
+ payment_id = response.body.fetch("ID")
152
+ ```
153
+
154
+ `PAYMENT` requires an authorized invoice; `REFUND` requires an authorized credit note. Amounts use customer-currency major units, not cents. `Account` is a chart-of-accounts code. Payments backed by `CreditID` cannot have their `Amount` or `Account` updated, and prepayments cannot be updated. The client preserves supplied FX precision, including five-place values such as `1.42541`; it does not truncate to the Blueprint's four-place annotation.
155
+
156
+ Order and invoice writes require the appropriate draft/not-available state. The method name `create` denotes POST: sale document POSTs can replace existing collections. Preserve all unrelated lines, charges, taxes and discounts. An invoice PUT can omit collections; supplying an empty collection deletes its contents. The all-zero `TaskID` explicitly requests a new invoice; the client never supplies it automatically.
157
+
158
+ Undo uses `Void=false` and can reverse downstream work and accounting. Snapshot documents/payments before removal or undo, and verify fulfilment, locks, export status, and the endpoint's eligibility first. The v2 Blueprint contains contradictory invoice-undo eligibility text; exposing the HTTP operation does not establish that it is safe for every simple or advanced sale. The client performs no automatic payment restoration, native credit-note creation, restocking, or fulfilment writes.
159
+
160
+ ### Journals and setup
161
+
162
+ Sale manual-journal payloads contain `SaleID`, `Status` (`DRAFT`/`AUTHORISED`) and `Lines` of `Reference`, `Amount`, `Date`, `Debit`, `Credit`. Their amounts are in **company base currency**. Read and retain all existing lines when appending a bonus entry.
163
+
164
+ Standalone journals contain `Status` (`DRAFT`/`COMPLETED`), `Currency`, `CurrencyConversionRate`, `EffectiveDate`, `Narration`, `Notes` and `Lines` of `Debit`, `Credit`, `Reference`, `Amount`, `BaseAmount`. `Amount` uses the selected currency; `BaseAmount` uses company base currency. The caller owns conversions and rounding. `journals.list(search:)` searches `Narration`/`Notes` as well as journal number/status. Keep a durable unique business reference in searchable fields. An uncertain manual-journal result is not permission to create a standalone replacement.
165
+
166
+ Account and tax reads do not prove connector mappings. Validate active/payment-enabled liability accounts, merchant mappings, active sale tax rules, and `/me` lock/currency settings in the application. Journal availability can depend on the accounting integration; verify it before choosing a fallback.
167
+
168
+ ### Webhooks
169
+
170
+ ```ruby
171
+ client.webhooks.create(payload: {
172
+ "Type" => "Sale/CreditNoteAuthorised",
173
+ "IsActive" => true,
174
+ "ExternalURL" => callback_url,
175
+ "ExternalAuthorizationType" => "bearerauth",
176
+ "ExternalBearerToken" => callback_token
177
+ })
178
+ ```
179
+
180
+ The Blueprint supports bearer/basic authentication, not an HMAC signature contract. Webhooks require the Automation module and are limited to five subscriptions per event type. It documents six delivery attempts: first after one minute, then delays of 5, 10, 15, 20 and 25 minutes, followed by deactivation on repeated failure. Manage only subscriptions owned by your application. Use polling as recovery and fetch authoritative sale state after notifications; `SaleID`, `SaleTaskID`, and `TaskID` differ across event payloads.
181
+
182
+ ## Errors and retries
183
+
184
+ Non-success responses raise an error with the response status, sanitized headers, and sanitized parsed body attached:
185
+
186
+ ```ruby
187
+ begin
188
+ client.sales.retrieve(id: sale_id)
189
+ rescue Cin7CoreAPI::RateLimitError => error
190
+ error.status # => 429
191
+ error.retry_after # value of the Retry-After response header, when present
192
+ error.body # CIN7's parsed error body
193
+ end
194
+ ```
195
+
196
+ Errors include:
197
+
198
+ - `Cin7CoreAPI::BadRequestError`
199
+ - `Cin7CoreAPI::AuthenticationError`
200
+ - `Cin7CoreAPI::ForbiddenError`
201
+ - `Cin7CoreAPI::NotFoundError`
202
+ - `Cin7CoreAPI::MethodNotAllowedError`
203
+ - `Cin7CoreAPI::RateLimitError`
204
+ - `Cin7CoreAPI::ServerError`
205
+ - `Cin7CoreAPI::TransportError`
206
+ - `Cin7CoreAPI::ParseError`
207
+
208
+ All inherit from `Cin7CoreAPI::Error`.
209
+
210
+ The gem does not retry requests automatically. CIN7 Core limits an API application to 60 calls per minute, and the calling application is better placed to apply queueing, idempotency, backoff, and retry policies.
211
+
212
+ For POST, PUT and DELETE, `error.ambiguous?` is true after transport failures, HTTP 5xx responses, or invalid/empty successful JSON responses (except HTTP 204). A write may already have applied. `error.request_method` is a Symbol (`:post`, `:put`, `:delete`, or `:get`), and `error.request_path` identifies the endpoint without query parameters. Reads and HTTP 4xx rejections return `ambiguous? == false`; this flag describes uncertainty about a mutation, not general retryability. HTTP 429 retains `retry_after` when present.
213
+
214
+ ```ruby
215
+ begin
216
+ client.payments.create(payload: saved_payload)
217
+ rescue Cin7CoreAPI::Error => error
218
+ if error.ambiguous?
219
+ # Persist recovery state. Read payments for the parent sale and match the
220
+ # saved reference, task, type, amount, account, date and FX before retrying.
221
+ # A missing immediate readback does not prove the write failed.
222
+ else
223
+ # Handle the sanitized rejection according to application policy.
224
+ end
225
+ end
226
+ ```
227
+
228
+ Neither a payment reference nor a journal reference is a documented server-side idempotency key. The gem supplies no exactly-once guarantee. It does not switch journal strategies, undo a sale, or replay a write after failure.
229
+
230
+ ### Sensitive data
231
+
232
+ The transport removes known API credentials and credential-bearing fields from response bodies/headers, including webhook tokens, passwords, usernames, authorization/cookie headers, and custom `ExternalHeaders`. It also filters known credential values echoed in other text. JSON field names stay intact; GUID identifiers are filtered only when the complete value matches a known credential. Outbound payloads are sent unchanged, and caller-owned Hashes are not mutated. Response and client/connection/resource inspection omit payloads and authentication state. Error messages omit response bodies; inspect `error.body` for sanitized details. Transport errors deliberately omit the upstream message and cause, which may contain the authenticated request.
233
+
234
+ Business `AccountID` and `BankAccountId` values remain GUIDs or null; they are distinct from the `api-auth-accountid` credential header. Callback `ExternalURL` values retain their spelling and escaping for subscription matching. URL userinfo and sensitive query/fragment parameters are filtered, as are complete credential values in URL components; incidental substrings from other subscriptions' credentials do not rewrite callback identity. URL credentials are also removed from free-text echoes. Malformed callback URLs are filtered entirely.
235
+
236
+ Keep webhook credentials in your own encrypted configuration: sanitized webhook responses are **not** suitable for round-tripping as update payloads. Redaction targets credentials; ordinary business data and customer information still require appropriate application logging controls.
237
+
238
+ ## Development
239
+
240
+ ```console
241
+ bundle install
242
+ bundle exec rake
243
+ bundle exec rake build
244
+ ```
245
+
246
+ The default Rake task runs the RSpec suite and Standard Ruby.
247
+
248
+ For a checkout containing untracked research scripts, lint only the intended gem source, not research artifacts:
249
+
250
+ ```console
251
+ mise exec -- bundle exec rspec
252
+ mise exec -- bundle exec standardrb --cache false $(git ls-files '*.rb' '*.gemspec' Gemfile Rakefile)
253
+ mise exec -- bundle exec rake build
254
+ ```
255
+
256
+ Stage intended new source/spec files before using the tracked-file lint command. CI tests Ruby 3.3 and 3.4. Specs use Faraday's test adapter and do not verify live accounting behavior. Inspect the built gem's file list before release: the gemspec packages only `lib/**/*.rb`, README, changelog and license. Preserve untracked research files. Publication and push require a separate release decision; RubyGems MFA remains required.
257
+
258
+ ## CIN7 references
259
+
260
+ - [CIN7 Core API v2 reference](https://dearinventory.docs.apiary.io/)
261
+ - [Connecting to the CIN7 Core API](https://help.core.cin7.com/hc/en-us/articles/9982480315407-Connecting-to-the-Cin7-Core-API)
262
+ - [CIN7 Core Shopify integration](https://help.core.cin7.com/hc/en-us/articles/11797034961039-Shopify-settings)
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cin7CoreAPI
4
+ class Client
5
+ DEFAULT_BASE_URL = "https://inventory.dearsystems.com/ExternalApi/v2/"
6
+ DEFAULT_OPEN_TIMEOUT = 5
7
+ DEFAULT_TIMEOUT = 30
8
+
9
+ attr_reader :connection
10
+
11
+ def initialize(
12
+ account_id:,
13
+ application_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!(:account_id, account_id)
20
+ validate_credential!(:application_key, application_key)
21
+
22
+ @connection = Connection.new(
23
+ account_id: account_id,
24
+ application_key: application_key,
25
+ base_url: base_url,
26
+ open_timeout: open_timeout,
27
+ timeout: timeout,
28
+ adapter: adapter
29
+ )
30
+ end
31
+
32
+ def me
33
+ @me ||= Resources::Me.new(connection)
34
+ end
35
+
36
+ def sales
37
+ @sales ||= Resources::Sales.new(connection)
38
+ end
39
+
40
+ def credit_notes
41
+ @credit_notes ||= Resources::CreditNotes.new(connection)
42
+ end
43
+
44
+ def payments
45
+ @payments ||= Resources::Payments.new(connection)
46
+ end
47
+
48
+ def locations
49
+ @locations ||= Resources::Locations.new(connection)
50
+ end
51
+
52
+ def accounts
53
+ @accounts ||= Resources::Accounts.new(connection)
54
+ end
55
+
56
+ def bank_accounts
57
+ @bank_accounts ||= Resources::BankAccounts.new(connection)
58
+ end
59
+
60
+ def customers
61
+ @customers ||= Resources::Customers.new(connection)
62
+ end
63
+
64
+ def customer_credits
65
+ @customer_credits ||= Resources::CustomerCredits.new(connection)
66
+ end
67
+
68
+ def orders
69
+ @orders ||= Resources::Orders.new(connection)
70
+ end
71
+
72
+ def invoices
73
+ @invoices ||= Resources::Invoices.new(connection)
74
+ end
75
+
76
+ def manual_journals
77
+ @manual_journals ||= Resources::ManualJournals.new(connection)
78
+ end
79
+
80
+ def journals
81
+ @journals ||= Resources::Journals.new(connection)
82
+ end
83
+
84
+ def tax_rules
85
+ @tax_rules ||= Resources::TaxRules.new(connection)
86
+ end
87
+
88
+ def webhooks
89
+ @webhooks ||= Resources::Webhooks.new(connection)
90
+ end
91
+
92
+ def inspect
93
+ "#<#{self.class}>"
94
+ end
95
+
96
+ private
97
+
98
+ def validate_credential!(name, value)
99
+ return if value.is_a?(String) && !value.strip.empty?
100
+
101
+ raise ArgumentError, "#{name} must be a non-empty String"
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Cin7CoreAPI
7
+ class Connection
8
+ ERROR_CLASSES = {
9
+ 400 => BadRequestError,
10
+ 401 => AuthenticationError,
11
+ 403 => ForbiddenError,
12
+ 404 => NotFoundError,
13
+ 405 => MethodNotAllowedError,
14
+ 429 => RateLimitError
15
+ }.freeze
16
+
17
+ def initialize(account_id:, application_key:, base_url:, open_timeout:, timeout:, adapter:)
18
+ @redactor = Redactor.new([account_id, application_key])
19
+ @http = Faraday.new(url: normalize_base_url(base_url)) do |connection|
20
+ connection.headers["Accept"] = "application/json"
21
+ connection.headers["Content-Type"] = "application/json"
22
+ connection.headers["User-Agent"] = "cin7_core_api/#{VERSION}"
23
+ connection.headers["api-auth-accountid"] = account_id
24
+ connection.headers["api-auth-applicationkey"] = application_key
25
+ connection.options.open_timeout = open_timeout
26
+ connection.options.timeout = timeout
27
+ connection.adapter(*Array(adapter))
28
+ end
29
+ end
30
+
31
+ def get(path, params: {})
32
+ request(:get, path, params: params)
33
+ end
34
+
35
+ def post(path, payload:)
36
+ request(:post, path, payload: payload)
37
+ end
38
+
39
+ def put(path, payload:)
40
+ request(:put, path, payload: payload)
41
+ end
42
+
43
+ def delete(path, params: {})
44
+ request(:delete, path, params: params)
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class}>"
49
+ end
50
+
51
+ private
52
+
53
+ def normalize_base_url(base_url)
54
+ base_url.end_with?("/") ? base_url : "#{base_url}/"
55
+ end
56
+
57
+ def request(method, path, params: {}, payload: nil)
58
+ # Serialize before dispatch: invalid input is not an ambiguous remote write.
59
+ body = JSON.generate(payload) unless payload.nil?
60
+ redactor = @redactor.with_sensitive_values(payload)
61
+ context = {request_method: method, request_path: redactor.filter(path.split("?").first)}
62
+ write = method != :get
63
+
64
+ raw_response = @http.run_request(method, path, body, nil) do |request|
65
+ request.params.update(params)
66
+ end
67
+ parsed_body, valid_json = parse_body(raw_response.body)
68
+ values = {"headers" => normalized_headers(raw_response.headers), "body" => parsed_body}
69
+ sanitized = redactor.with_sensitive_values(values).filter(values)
70
+ response = Response.new(status: raw_response.status, headers: sanitized.fetch("headers"), body: sanitized.fetch("body"))
71
+
72
+ raise_for_status!(response, context, write: write)
73
+ if response.status != 204 && (!valid_json || (write && parsed_body.nil?))
74
+ raise ParseError.new("CIN7 Core returned invalid or empty JSON", response: response, ambiguous: write, **context)
75
+ end
76
+
77
+ response
78
+ rescue Faraday::Error => error
79
+ # Faraday exceptions can retain the entire authenticated request in their cause.
80
+ raise TransportError.new("CIN7 Core transport failed (#{error.class})", ambiguous: write, **context), cause: nil
81
+ end
82
+
83
+ def normalized_headers(headers)
84
+ headers.to_h.transform_keys { |key| key.to_s.downcase }
85
+ end
86
+
87
+ def parse_body(body)
88
+ return [nil, true] if body.nil? || body.empty?
89
+
90
+ [JSON.parse(body), true]
91
+ rescue JSON::ParserError
92
+ [body, false]
93
+ end
94
+
95
+ def raise_for_status!(response, context, write:)
96
+ return if response.success?
97
+
98
+ error_class = ERROR_CLASSES.fetch(response.status) do
99
+ (response.status >= 500) ? ServerError : Error
100
+ end
101
+
102
+ # Details remain available in the sanitized body, never in log-friendly messages.
103
+ raise error_class.new("CIN7 Core request failed with HTTP #{response.status}",
104
+ response: response, ambiguous: write && response.status >= 500, **context)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cin7CoreAPI
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 RateLimitError < Error; end
42
+ class ServerError < Error; end
43
+ class TransportError < Error; end
44
+ class ParseError < Error; end
45
+ end